diff --git a/README.md b/README.md index 33db57b..895d53f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # Computed -Computed is a NeoForge 1.21.1 programmable node-graph mod. Its node API, persisted program format, -runtime, and editor live entirely under Computed-owned Java and resource namespaces, avoiding the -split-package conflict caused by the formerly vendored Web's Node Lib. +Computed is a NeoForge 1.21.1 programmable Lua node-graph mod. LuaJ powers one sandboxed VM per +computer while Computed owns graph scheduling, safe Minecraft endpoints, persistence, networking, +the editor, monitor rendering, and integrations. -Legacy worlds and imports are migrated on load and written as version-2 `ComputedProgram` data after -the next successful save. Share exports use `CMP2`; `CMP1` and legacy Base64/SNBT remain import-only. +Program format 3 stores one root graph and an embedded Lua definition library. Legacy graphs, +Functions, Sections, JSON custom nodes, CMP1, and CMP2 are intentionally discarded or rejected +without migration. -Addon authors: see the [public node API](docs/node-api.md), the -[Web's Node Lib migration guide](docs/node-migration.md), and the -[example addon](docs/example-addon/README.md). +Start with the [Lua authoring guide](docs/lua/authoring-guide.md), the +[Lua method reference](docs/lua/lua-api-reference.md), and the +[Java endpoint API](docs/lua/endpoint-api.md). Build and verify with: @@ -17,10 +18,6 @@ Build and verify with: ./gradlew clean check build ``` -The `check` lifecycle verifies that the built JAR contains no `dev/devce/websnodelib/**` classes or -`assets/websnodelib/**` resources. - - # Credits – Third-Party Code Computed now ships its own node engine, persistence model, runtime, and editor under the diff --git a/build.gradle b/build.gradle index 482660b..cb05522 100644 --- a/build.gradle +++ b/build.gradle @@ -66,6 +66,13 @@ repositories { includeGroup 'dev.ryanhcode.sable' } } + maven { + name = 'CC-Tweaked' + url = 'https://maven.squiddev.cc' + content { + includeGroup 'cc.tweaked' + } + } flatDir { dirs 'libs' @@ -160,6 +167,10 @@ configurations { } dependencies { + implementation 'org.luaj:luaj-jse:3.0.1' + jarJar 'org.luaj:luaj-jse:3.0.1' + additionalRuntimeClasspath 'org.luaj:luaj-jse:3.0.1' + testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.slf4j:slf4j-api:2.0.17' @@ -183,6 +194,14 @@ dependencies { // but the SableCompanion class itself only exists at runtime if the companion mod is installed. compileOnly "dev.ryanhcode.sable-companion:sable-companion-common-1.21.1:1.5.0" + compileOnly "cc.tweaked:cc-tweaked-1.21.1-core-api:${cct_version}" + compileOnly "cc.tweaked:cc-tweaked-1.21.1-common-api:${cct_version}" + compileOnly "cc.tweaked:cc-tweaked-1.21.1-forge-api:${cct_version}" + testImplementation "cc.tweaked:cc-tweaked-1.21.1-core-api:${cct_version}" + testImplementation "cc.tweaked:cc-tweaked-1.21.1-common-api:${cct_version}" + testImplementation "cc.tweaked:cc-tweaked-1.21.1-forge-api:${cct_version}" + localRuntime "cc.tweaked:cc-tweaked-1.21.1-forge:${cct_version}" + // Other dev-only jars in /libs (not published); skip Create/Flywheel/Ponder filenames so Maven deps are not duplicated. localRuntime fileTree(dir: layout.projectDirectory.dir('libs'), include: ['*.jar']).matching { exclude 'flywheel*.jar' @@ -256,6 +275,20 @@ tasks.withType(JavaCompile).configureEach { tasks.named('test', Test).configure { useJUnitPlatform() + useJUnitPlatform { + excludeTags 'benchmark' + } +} + +tasks.register('benchmarkLua', Test) { + group = 'verification' + description = 'Runs the 500-node Lua scheduler acceptance benchmark.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'benchmark' + } + shouldRunAfter tasks.named('test') } def verifyNoLegacyNodeNamespace = tasks.register('verifyNoLegacyNodeNamespace') { diff --git a/docs/custom-nodes-example-add.json b/docs/custom-nodes-example-add.json deleted file mode 100644 index f8d1f2c..0000000 --- a/docs/custom-nodes-example-add.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "computed:example_add", - "label": "Example Add", - "menuPath": ["Custom", "Examples"], - "inputs": [ - { "name": "A", "color": "#00FF88" }, - { "name": "B", "color": "#00FF88" } - ], - "outputs": [ - { "name": "Result", "color": "#FF5555", "expression": "A + B" } - ], - "constants": { - "bias": 0.0 - } -} diff --git a/docs/custom-nodes.md b/docs/custom-nodes.md deleted file mode 100644 index 5e1f927..0000000 --- a/docs/custom-nodes.md +++ /dev/null @@ -1,238 +0,0 @@ -# Custom Nodes (v2) - -Custom nodes are loaded from: - -- `config/computed/nodes/` - -Loader behavior: - -- Recursively loads `*.json`. Ignores `*.md` files. -- Skips invalid files and logs warnings/errors. -- Skips IDs that conflict with built-in or already-registered nodes. -- **Reload live**: `/computed reload` - ---- - -## JSON schema - -```json -{ - "id": "computed:my_node", - "label": "My Node", - "menuPath": ["Custom", "Math"], - "inputs": [ - { "name": "A", "type": "number", "color": "#00FF88" }, - { "name": "Tag", "type": "string", "color": "#FFC830" } - ], - "outputs": [ - { "name": "Sum", "type": "number", "color": "#FF5555", "expression": "A + gain" }, - { "name": "Label", "type": "string", "color": "#FFC830", "expression": "concat(Tag, \" = \", str(A))" } - ], - "constants": { - "gain": 2.0 - }, - "state": [ - { "name": "count", "init": 0, "update": "count + 1" } - ] -} -``` - -### Fields - -| Field | Type | Required | Description | -|---|---|---|---| -| `id` | string | ✓ | `namespace:path`. Must be unique. | -| `label` | string | ✓ | Display name shown on the node. | -| `menuPath` | string[] | | Category path in the add-node menu. Defaults to `["Custom"]`. | -| `inputs` | array | | List of input pin specs (see below). | -| `outputs` | array | ✓ | List of output pin specs. Must have at least one. | -| `constants` | object | | Named numeric constants accessible in all expressions. | -| `state` | array | | Persistent state variables (see **Persistent state** section). | - -### Pin spec (`inputs` / `outputs`) - -| Field | Type | Default | Description | -|---|---|---|---| -| `name` | string | ✓ | Pin label. Must be unique across all inputs and outputs. | -| `type` | `"number"` \| `"string"` | `"number"` | Data type of the pin. | -| `color` | `"#RRGGBB"` or `"#AARRGGBB"` | auto | Pin accent colour. | -| `expression` | string | ✓ (outputs only) | Expression that computes this output's value every tick. | - ---- - -## Expressions - -### Variables - -Available in all expressions: -- Input pin names (case-insensitive) -- Constant names from `constants` -- State variable names from `state` -- Local variables defined in the same multi-step expression - -### Multi-step programs - -Statements are separated by `;`. The value of the **last statement** is the result. -Assignment (`name = expr`) writes a local variable usable in later statements of the same expression. - -``` -"expression": "diff = A - B; abs(diff)" -``` - -### String literals - -Use `"..."` or `'...'` in expressions: - -``` -"expression": "concat(\"temp: \", str(A), \"°C\")" -``` - -### Operators - -`+ - * / %`, comparisons (`< <= > >= == !=`), logical (`&& || !`), parentheses. - -`+` performs string concatenation when either operand is a string. - -Boolean values: `1.0` = true, `0.0` = false. Threshold: `> 0.5`. - ---- - -## Built-in functions - -### Math - -| Function | Description | -|---|---| -| `min(a, b)` | Minimum | -| `max(a, b)` | Maximum | -| `abs(x)` | Absolute value | -| `sqrt(x)` | Square root | -| `pow(a, b)` | Power | -| `floor(x)` | Floor | -| `ceil(x)` | Ceiling | -| `round(x)` | Round to nearest | -| `sign(x)` | Signum | -| `clamp(x, lo, hi)` | Clamp x to [lo, hi] | -| `lerp(lo, hi, t)` | Linear interpolation | -| `log(x)` / `log(x, base)` | Natural or base logarithm | -| `exp(x)` | e^x | -| `sin(x)`, `cos(x)`, `tan(x)` | Trig (radians) | -| `asin(x)`, `acos(x)`, `atan(x)` | Inverse trig | -| `atan2(y, x)` | Two-argument arctangent | -| `hypot(a, b)` | Hypotenuse | -| `rad(deg)` | Degrees → radians | -| `deg(rad)` | Radians → degrees | -| `if(cond, a, b)` | Conditional — returns `a` if `cond > 0.5`, else `b` | - -### String - -| Function | Description | -|---|---| -| `str(x)` | Convert number to string | -| `num(s)` | Parse string to number | -| `concat(a, b, ...)` | Concatenate any number of values | -| `len(s)` | String length | -| `substr(s, start[, end])` | Substring | -| `upper(s)` / `lower(s)` | Case conversion | -| `contains(s, sub)` | 1 if s contains sub | -| `starts_with(s, prefix)` | 1 if s starts with prefix | -| `ends_with(s, suffix)` | 1 if s ends with suffix | -| `replace(s, old, new)` | Replace all occurrences | -| `format(fmt, args...)` | Java `String.format` style | - ---- - -## Persistent state - -State variables hold their value across ticks. Define them in the `state` array: - -```json -"state": [ - { "name": "count", "init": 0, "update": "count + 1" }, - { "name": "prev", "init": 0.0, "update": "A" } -] -``` - -- `name` — variable name (accessible in output expressions and other update expressions) -- `init` — initial value (number or string). Defaults to `0`. -- `update` — expression evaluated **each tick** to produce the next value. The snapshot of the *previous tick* is used for all updates, so updates are independent of each other. - -State is saved to NBT and survives chunk unload / world reload. - -### Stateful helper functions - -These are implemented on top of the state store and keyed by call-site position: - -| Function | Description | -|---|---| -| `prev(x)` / `prev(x, default)` | Returns the value `x` had last tick | -| `rising(x)` | 1 on the tick `x` transitions from false → true | -| `falling(x)` | 1 on the tick `x` transitions from true → false | -| `changed(x)` | 1 when `x` is different from last tick | - ---- - -## World source functions - -These read from the Minecraft world. Available in all JSON node expressions. All face arguments -accept `"front"`, `"back"`, `"left"`, `"right"`, `"top"`, `"bottom"` (case-insensitive). -Return defaults (0 / "") when executed outside a world tick. - -### Environment - -| Function | Returns | Description | -|---|---|---| -| `light_level()` | 0–15 | Max of sky + block light at computer | -| `light_sky()` | 0–15 | Sky light level | -| `light_block()` | 0–15 | Block light level | -| `is_raining()` | 0/1 | Is it raining? | -| `is_thundering()` | 0/1 | Is it thundering? | -| `is_day()` | 0/1 | Is it daytime? | -| `biome_temp()` | float | Biome base temperature | -| `biome_downfall()` | float | Biome downfall value | -| `biome_name()` | string | Biome resource location (e.g. `"minecraft:plains"`) | - -### Block - -| Function | Returns | Description | -|---|---|---| -| `block_id(face)` | string | Block's registry ID at that face (e.g. `"minecraft:dirt"`) | -| `block_is(id, face)` | 0/1 | 1 if the block at `face` matches the given registry ID | - -### Fluid - -| Function | Returns | Description | -|---|---|---| -| `fluid_present(face)` | 0/1 | Is there a fluid at that face? | -| `fluid_level(face)` | 0–8 | Fluid fill amount (0 = none) | -| `fluid_type(face)` | string | `"water"`, `"lava"`, or `""` | - -### Inventory / container - -| Function | Returns | Description | -|---|---|---| -| `container_slots(face)` | int | Slot count of adjacent inventory | -| `container_count(face)` | int | Total items across all slots | -| `container_fill(face)` | 0.0–1.0 | Fill fraction (used / capacity) | -| `comparator(face)` | 0–15 | Analog comparator signal (fallback: weak redstone) | - ---- - -## Create mod source functions - -Only available when the Create mod is installed. Face argument follows the same convention as world functions. - -| Function | Returns | Description | -|---|---|---| -| `create_kinetic(face)` | 0/1 | Is the adjacent block a Create kinetic block? | -| `create_speed(face)` | float | Speed in RPM (signed; negative = reversed) | -| `create_stress(face)` | float | Stress currently applied by that block | -| `create_capacity(face)` | float | Stress capacity contributed (sources only) | - -> **Note:** Create redstone link transmit/receive uses the dedicated **Create Redstone Link Sender/Receiver** nodes in the node graph, not expression functions, because they require per-instance network actor registration. - ---- - -## Examples - -See the `docs/examples/` folder for ready-to-use JSON files. diff --git a/docs/example-addon/README.md b/docs/example-addon/README.md deleted file mode 100644 index 3146c3a..0000000 --- a/docs/example-addon/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Example Computed addon - -This source tree is documentation and is intentionally outside the project's compiled source sets. - -Call `ExampleNodes.register()` from the addon's common registration phase. Call -`ExampleNodeClient.registerPresentations()` from a loader-provided client-only initialization hook. -Both calls must happen before Computed freezes the corresponding registry. - -The example node accumulates a scaled input. Its immutable state is a dependency boundary, while its -optional presentation reuses the editor's generated property control before drawing custom content. diff --git a/docs/example-addon/src/main/java/com/example/computedaddon/ExampleNodes.java b/docs/example-addon/src/main/java/com/example/computedaddon/ExampleNodes.java deleted file mode 100644 index c29b96e..0000000 --- a/docs/example-addon/src/main/java/com/example/computedaddon/ExampleNodes.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.example.computedaddon; - -import com.mojang.serialization.Codec; -import dev.propulsionteam.computed.api.node.ComputedNodeApi; -import dev.propulsionteam.computed.api.node.ExecutionPolicy; -import dev.propulsionteam.computed.api.node.NodeProperty; -import dev.propulsionteam.computed.api.node.NodeSchema; -import dev.propulsionteam.computed.api.node.NodeType; -import dev.propulsionteam.computed.api.node.PortKey; -import dev.propulsionteam.computed.api.node.PortType; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -/** Common-side registration example. */ -public final class ExampleNodes { - public static final ResourceLocation CATEGORY = id("examples"); - public static final PortKey AMOUNT = PortKey.of("amount", PortType.NUMBER); - public static final PortKey RESET = PortKey.of("reset", PortType.NUMBER); - public static final PortKey TOTAL = PortKey.of("total", PortType.NUMBER); - public static final NodeProperty SCALE = NodeProperty.builder( - "scale", Component.literal("Scale"), Double.class, Codec.DOUBLE) - .defaultValue(1.0D) - .validator(value -> Double.isFinite(value) && value >= 0.0D, "must be finite and non-negative") - .build(); - - private static final Codec STATE_CODEC = - Codec.DOUBLE.xmap(AccumulatorState::new, AccumulatorState::total); - - public static final NodeType ACCUMULATOR = NodeType.builder(id("accumulator")) - .title(Component.literal("Accumulator")) - .category(CATEGORY) - .property(SCALE) - .schema(NodeSchema.builder() - .input(AMOUNT, Component.literal("Amount")) - .input(RESET, Component.literal("Reset")) - .output(TOTAL, Component.literal("Total")) - .build()) - .stateCodec(STATE_CODEC) - .defaultState(new AccumulatorState(0.0D)) - .stateBoundary(true) - .executionPolicy(ExecutionPolicy.EVERY_GRAPH_STEP) - .evaluator((priorState, context) -> { - double nextTotal = context.input(RESET) > 0.0D - ? 0.0D - : priorState.total() + context.input(AMOUNT) * context.properties().get(SCALE); - context.output(TOTAL, nextTotal); - return new AccumulatorState(nextTotal); - }) - .build(); - - private ExampleNodes() {} - - public static void register() { - ComputedNodeApi.registerCategory(CATEGORY, Component.literal("Example addon"), ComputedNodeApi.ROOT_CATEGORY); - ComputedNodeApi.register(ACCUMULATOR); - } - - private static ResourceLocation id(String path) { - return ResourceLocation.fromNamespaceAndPath("computed_example", path); - } - - /** State values are replaced after each graph step rather than mutated in place. */ - public record AccumulatorState(double total) {} -} diff --git a/docs/example-addon/src/main/java/com/example/computedaddon/client/ExampleNodeClient.java b/docs/example-addon/src/main/java/com/example/computedaddon/client/ExampleNodeClient.java deleted file mode 100644 index 7bd98e1..0000000 --- a/docs/example-addon/src/main/java/com/example/computedaddon/client/ExampleNodeClient.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.computedaddon.client; - -import com.example.computedaddon.ExampleNodes; -import dev.propulsionteam.computed.api.node.client.ComputedNodeClientApi; -import net.minecraft.client.Minecraft; -import net.minecraft.network.chat.Component; - -/** Invoke only from the addon's client initialization path. */ -public final class ExampleNodeClient { - private ExampleNodeClient() {} - - public static void registerPresentations() { - ComputedNodeClientApi.registerPresentation(ExampleNodes.ACCUMULATOR, context -> { - context.renderGenericPropertyControls(); - context.graphics().drawString( - Minecraft.getInstance().font, - Component.literal("Keeps its total between steps"), - context.x() + 6, - context.y() + context.height() - 14, - 0xFFB8C4D8, - false); - }); - } -} diff --git a/docs/examples/cake_detector.json b/docs/examples/cake_detector.json deleted file mode 100644 index ebfc806..0000000 --- a/docs/examples/cake_detector.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "id": "computed:cake_detector", - "label": "Cake Detector", - "menuPath": ["Custom", "Detectors"], - "outputs": [ - { - "name": "Present", - "color": "#FF5599", - "expression": "comparator(\"front\") > 0" - }, - { - "name": "Signal", - "color": "#FFAA00", - "expression": "comparator(\"front\")" - }, - { - "name": "Slices Left", - "color": "#FF88AA", - "expression": "s = comparator(\"front\"); if(s > 0, s, 0)" - }, - { - "name": "Slices Eaten", - "color": "#994422", - "expression": "s = comparator(\"front\"); if(s > 0, 7 - s, 0)" - } - ] -} diff --git a/docs/examples/create_kinetic_monitor.json b/docs/examples/create_kinetic_monitor.json deleted file mode 100644 index 1adeb08..0000000 --- a/docs/examples/create_kinetic_monitor.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "computed:example_create_kinetic", - "label": "Kinetic Monitor", - "menuPath": ["Custom", "Examples", "Create"], - "inputs": [], - "outputs": [ - { "name": "IsKinetic", "color": "#FFAA00", "expression": "create_kinetic(\"front\")" }, - { "name": "Speed", "color": "#FF6600", "expression": "create_speed(\"front\")" }, - { "name": "Stress", "color": "#FF3333", "expression": "create_stress(\"front\")" }, - { "name": "Capacity", "color": "#33FF88", "expression": "create_capacity(\"front\")" } - ] -} diff --git a/docs/examples/dirt_detector.json b/docs/examples/dirt_detector.json deleted file mode 100644 index 1cd46f3..0000000 --- a/docs/examples/dirt_detector.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "computed:dirt_detector", - "label": "Dirt Detector", - "menuPath": ["Custom", "Detectors"], - "outputs": [ - { - "name": "Is Dirt", - "color": "#8B5E3C", - "expression": "block_is(\"minecraft:dirt\", \"front\")" - }, - { - "name": "Block ID", - "type": "string", - "color": "#AAAAAA", - "expression": "block_id(\"front\")" - } - ] -} diff --git a/docs/examples/multistep_clamp.json b/docs/examples/multistep_clamp.json deleted file mode 100644 index e075c41..0000000 --- a/docs/examples/multistep_clamp.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "computed:example_multistep", - "label": "Clamped Difference", - "menuPath": ["Custom", "Examples"], - "inputs": [ - { "name": "A", "color": "#00FF88" }, - { "name": "B", "color": "#FF5555" } - ], - "outputs": [ - { "name": "Diff", "color": "#FFFFFF", - "expression": "d = A - B; clamp(d, -10, 10)" }, - { "name": "AbsDiff","color": "#FFAA00", - "expression": "abs(A - B)" }, - { "name": "Rising", "color": "#55FF55", - "expression": "rising(A > B)" } - ] -} diff --git a/docs/examples/state_counter.json b/docs/examples/state_counter.json deleted file mode 100644 index fe6c54a..0000000 --- a/docs/examples/state_counter.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "computed:example_counter", - "label": "Tick Counter", - "menuPath": ["Custom", "Examples"], - "inputs": [ - { "name": "Reset", "color": "#FF5555" } - ], - "outputs": [ - { "name": "Count", "color": "#00FF88", "expression": "count" } - ], - "state": [ - { "name": "count", "init": 0, "update": "if(Reset > 0.5, 0, count + 1)" } - ] -} diff --git a/docs/examples/string_label.json b/docs/examples/string_label.json deleted file mode 100644 index b31b990..0000000 --- a/docs/examples/string_label.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "computed:example_string_label", - "label": "Sensor Label", - "menuPath": ["Custom", "Examples"], - "inputs": [ - { "name": "Value", "type": "number", "color": "#00FF88" }, - { "name": "Unit", "type": "string", "color": "#FFC830" } - ], - "outputs": [ - { "name": "Label", "type": "string", "color": "#FFC830", - "expression": "concat(str(round(Value)), \" \", Unit)" } - ] -} diff --git a/docs/examples/world_env_sensor.json b/docs/examples/world_env_sensor.json deleted file mode 100644 index 639be63..0000000 --- a/docs/examples/world_env_sensor.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "computed:example_env_sensor", - "label": "Environment Sensor", - "menuPath": ["Custom", "Examples"], - "inputs": [], - "outputs": [ - { "name": "Light", "color": "#FFFF55", "expression": "light_level()" }, - { "name": "Raining", "color": "#5599FF", "expression": "is_raining()" }, - { "name": "Thundering","color": "#9955FF", "expression": "is_thundering()" }, - { "name": "IsDay", "color": "#FFAA00", "expression": "is_day()" }, - { "name": "Biome", "type": "string", "color": "#55FF55", - "expression": "biome_name()" } - ] -} diff --git a/docs/examples/world_fluid_check.json b/docs/examples/world_fluid_check.json deleted file mode 100644 index 3f6583b..0000000 --- a/docs/examples/world_fluid_check.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "computed:example_fluid_check", - "label": "Fluid Checker", - "menuPath": ["Custom", "Examples"], - "inputs": [], - "outputs": [ - { "name": "Present", "color": "#5599FF", "expression": "fluid_present(\"front\")" }, - { "name": "Level", "color": "#55CCFF", "expression": "fluid_level(\"front\")" }, - { "name": "Type", "type": "string", "color": "#FFC830", - "expression": "fluid_type(\"front\")" } - ] -} diff --git a/docs/lua/architecture.md b/docs/lua/architecture.md new file mode 100644 index 0000000..3c5fbe9 --- /dev/null +++ b/docs/lua/architecture.md @@ -0,0 +1,17 @@ +# Architecture and Package Boundaries + +- `graph` owns immutable graphs, stable ports, deterministic analysis, and scheduling. +- `lua/compiler` owns validation, SHA-256 hashing, and prototype caching. +- `lua/runtime` owns per-computer VMs, transactional node instances, coroutines, and state serialization. +- `lua/sandbox` owns globals and instruction budgets. +- `lua/node` owns schemas, the fluent contract, bundled definitions, and embedded libraries. +- `lua/endpoint` owns safe Java registrations and preview fixtures. +- `client/editor` owns explorer and focused editor state. +- `client/renderer/node` owns the semantic palette and shared layout. +- `persistence` owns format 3 and the clean legacy reset. +- `network` remains authoritative for distance, permissions, revisions, and payload size. +- `integration/computercraft`, `integration/create`, and `integration/vanilla` own optional API boundaries and lifecycle cleanup. + +Minecraft and addon objects stop at endpoint handlers. Lua nodes communicate through scheduler-owned edges, never by directly calling neighbors. `WireEditorController` remains the unchanged compatibility boundary for curves, colors, thickness, pulses, waypoints, hit testing, and socket behavior. + +Format 3 stores one root graph, an embedded Lua library, definition ID/hash references, stable port snapshots, persistent state, revision, and metadata. It stores no Functions or Sections. diff --git a/docs/lua/authoring-guide.md b/docs/lua/authoring-guide.md new file mode 100644 index 0000000..5ef94c4 --- /dev/null +++ b/docs/lua/authoring-guide.md @@ -0,0 +1,64 @@ +# Lua Node Authoring Guide + +Every Computed node is a Lua definition. A file creates one node, declares an immutable schema, installs callbacks, and returns that node. + +```lua +local node = computed.node(1, "example:counter", "Counter") + +node:category("state") +node:style("standard") +node:input("increment", "number") +node:output("count", "number") +node:field("step", "number", { + default = 1, + min = 0, + max = 10, + control = "slider", + step = 0.5, + label = "Step Size" +}) +node:state("count", 0) + +node:on_run(function(ctx) + local next = ctx:state("count") + ctx:input("increment") * ctx:field("step") + ctx:set_state("count", next) + ctx:output("count", next) +end) + +return node +``` + +Definition IDs are stable, lowercase, namespaced identifiers. Port, field, state, and event IDs start with a lowercase letter and contain only lowercase letters, digits, `_`, `.`, or `-`. Schemas cannot change while an instance is running. + +Choose `input` execution for dataflow nodes, `tick` for world sensors, `step` for explicitly stepped nodes, and `event` when only named handlers should run. A failed invocation discards all staged output and state changes. The next eligible execution retries the callback. + +Use endpoints for Minecraft or addon access. Lua values never contain Java or Minecraft objects. + +## Create a node in a computer + +Open the Node Explorer, expand **User Nodes**, and choose **New Lua Node…**. The editor starts with a unique reusable `user:node_` definition. Edit and preview the source, then choose **Apply**. Computed adds the validated definition to that computer's embedded library and places its first instance at the canvas anchor. Additional instances appear under **User Nodes** and can be placed like bundled nodes. + +Right-click an existing definition in the explorer to edit it. Replacing a definition with the same ID requires confirmation when its source hash changes. + +## Field controls + +Field values belong to each node instance and participate in autosave, undo, duplication, and server validation. Use value controls for unrestricted numbers and explicit sliders for bounded values: + +```lua +node:field("name", "text", { default = "Display" }) +node:field("enabled", "boolean", { default = true }) +node:field("side", "direction", { default = "front" }) +node:field("mode", "choice", { + default = "normal", + choices = { "normal", "inverted" } +}) +node:field("speed", "number", { + default = 10, + min = 0, + max = 20, + control = "slider", + step = 1 +}) +``` + +See [Lua API Reference](lua-api-reference.md), [Types and State](types-and-state.md), [Sandbox and Budgets](sandbox-and-budgets.md), and the files under `docs/lua/examples`. diff --git a/docs/lua/computercraft.md b/docs/lua/computercraft.md new file mode 100644 index 0000000..d1d0a5c --- /dev/null +++ b/docs/lua/computercraft.md @@ -0,0 +1,57 @@ +# CC:Tweaked Integration + +CC:Tweaked support is optional and targets its public `dan200.computercraft.api` surface. It is peripheral interoperability, not CraftOS compatibility. + +Adjacent peripherals are exposed through `ctx:endpoint("computercraft:peripheral", side)`. A proxy provides `methods()` and `call(methodName, ...)`. Calls return an integer-keyed result table. Main-thread tasks and yielded `MethodResult` continuations suspend the node while committed outputs remain visible. Queued peripheral events resume pull-event callbacks. Unload, definition replacement, peripheral detach, or cancellation terminates the continuation safely. + +Dedicated CC Input and CC Output Lua nodes exchange named channel values. The Computed peripheral lists channels, reads outputs, writes inputs, and emits output-changed events. + +Filesystem, terminal, rednet, HTTP, and other CraftOS globals are not exposed. + +```lua +local node = computed.node(1, "example:cc_query", "CC Query") +node:field("side", "direction", { default = "left" }) +node:output("result", "table") +node:on_run(function(ctx) + local peripheral = ctx:endpoint("computercraft:peripheral", ctx:field("side")) + ctx:output("result", { methods = peripheral:methods() }) +end) +return node +``` + +## Calling an adjacent peripheral + +```lua +local node = computed.node(1, "example:cc_call", "CC Call") +node:category("integration/computercraft") +node:execution("tick") +node:field("side", "direction", { default = "left" }) +node:output("result", "table") +node:on_run(function(ctx) + local peripheral = ctx:endpoint("computercraft:peripheral", ctx:field("side")) + ctx:output("result", peripheral:call("getEnergy")) +end) +return node +``` + +If `getEnergy` completes immediately, the result table is committed in the same graph tick. If it returns a yielding `MethodResult`, the invocation remains private until its callback completes. The prior committed output remains visible while waiting. + +## Using Computed channels from CraftOS + +```lua +local computed = peripheral.find("computed") +computed.write("control", { enabled = true, level = 12 }) + +for _, name in ipairs(computed.listChannels()) do + print(name) +end + +local status = computed.read("status") +local event, channel, value = os.pullEvent("computed_output_changed") +``` + +`write(channel, value)` feeds a CC Input node. `read(channel)` reads the latest CC Output value. `listChannels()` returns input and output channel names. `computed_output_changed` is queued only when a published value changes. + +## Yielded call behavior + +Only values supported by both Computed state serialization and the CC API cross the bridge: nil, booleans, finite numbers, strings, and acyclic tables with string or integer keys. Filesystem mounts are refused. A detached peripheral fails its pending invocation, releases its attachment, and leaves committed state and outputs unchanged. diff --git a/docs/lua/endpoint-api.md b/docs/lua/endpoint-api.md new file mode 100644 index 0000000..b23f861 --- /dev/null +++ b/docs/lua/endpoint-api.md @@ -0,0 +1,144 @@ +# Java Endpoint API + +Endpoints are the only bridge from Lua to Minecraft and addons. Handlers receive the host object; Lua receives only validated values. + +## ComputedEndpoints.register(id, registration) + +Registers one stable namespaced endpoint during mod setup and returns its immutable definition. The registration callback receives an `EndpointBuilder`. It runs on the registration thread, is unavailable to Lua previews, and has the side effect of adding a global API entry. It throws on invalid or duplicate endpoint IDs. + +```java +ComputedEndpoints.register("addon:storage", endpoint -> endpoint + .method("stored", signature, policy, handler, previewFixture, "Returns stored units.")); +``` + +## EndpointBuilder.method(methodId, signature, policy, handler) + +Adds a method and returns the builder. Parameters declare stable ID, argument/return schema, execution/yield/side-effect/preview policy, and handler. The short overload marks preview unavailable. It throws on duplicate IDs or incomplete preview policy. + +## EndpointBuilder.method(methodId, signature, policy, handler, previewFixture, documentation) + +Adds a fully described method and returns the builder. `previewFixture` must be deterministic when preview is enabled. The documentation string feeds completion/signature help. Handler side effects must match the policy. + +## ComputedEndpoints.find(id) + +Returns an optional immutable endpoint definition. It has no side effects and may be called on either side for metadata lookup. + +## ComputedEndpoints.definitions() + +Returns all definitions sorted by stable ID. It has no side effects and powers documentation/completion checks. + +## EndpointSignature.of(arguments, returns) + +Creates a fixed-arity signature from ordered argument and return type lists. Empty lists mean no arguments or no returns. It returns an immutable signature, runs during registration on either side, has no preview behavior or side effects, and rejects no values beyond null lists becoming empty. Use the three-argument `EndpointSignature` constructor with `variadic = true` when trailing values are allowed. Example: `EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.NUMBER))`. + +## EndpointPolicy.computerThread(sideEffect, previewAvailable) + +Creates a non-yielding computer-thread policy. `sideEffect` declares production mutation and `previewAvailable` requires a deterministic fixture. It returns an immutable policy, runs during registration, and has no side effect itself. Use the record constructor for server-thread or yielding methods. Example: `EndpointPolicy.computerThread(false, true)`. + +## EndpointResult.immediate(values...) + +Returns validated Lua values without suspending the node. Parameters are zero or more LuaJ values; the default is no values. It can be returned on either execution side, has no side effect itself, and is used unchanged by preview fixtures. Return-schema mismatches become runtime errors. Example: `return EndpointResult.immediate(LuaValue.valueOf(12));`. + +## EndpointResult.yielded(continuation) + +Suspends the current node until the completion stage supplies an immediate result. The continuation is required and the endpoint policy must declare yielding. It is unavailable in preview unless a separate immediate fixture exists. Cancellation, exceptional completion, unload, definition replacement, or detach fails the invocation while retaining prior committed outputs. Example: `return EndpointResult.yielded(future);`. + +## EndpointResult.unavailable(reason) + +Returns an explicit unavailable result with a nonblank reason. Calling Lua receives a runtime error; no values or side effects are produced. It is suitable for optional integrations and blocked preview behavior. Example: `return EndpointResult.unavailable("machine is not loaded");`. + +## EndpointRuntimeLifecycle.register(listener) + +Registers tick and unload callbacks for endpoint-owned external state. The listener is required for useful behavior and is called with the computer ID and host. Registration mutates the global lifecycle list, runs during mod setup, and has no preview callback. Listeners must cancel pending work and release addon objects on unload. Example: `EndpointRuntimeLifecycle.register(listener);`. + +## computed:world/time + +Signature: `() -> number`. Runs on the computer thread, does not yield, has no side effects, and returns fixture `6000` in preview. Errors when the computer host cannot expose world time. Example: `ctx:endpoint("computed:world"):call("time")`. + +## computed:world/position + +Signature: `() -> number, number, number`. Runs on the computer thread, has no side effects, and is available in previews with a fixed position fixture. Errors when host world access is unavailable. Example: `local x, y, z = ctx:endpoint("computed:world"):call("position")`. + +## computed:world/rotation + +Signature: `() -> number, number, number`. Runs on the computer thread, has no side effects, and is available in previews with a fixed rotation fixture. Errors when host world access is unavailable. Example: `local yaw, pitch, roll = ctx:endpoint("computed:world"):call("rotation")`. + +## computed:world/block_present + +Signature: `(face: string) -> boolean`. Runs on the computer thread, has no side effects, and returns `false` in previews. Unknown face names return `false`; invalid argument types are errors. Example: `ctx:endpoint("computed:world"):call("block_present", "front")`. + +## computed:redstone/input + +Signature: `(face: string) -> number`. Runs on the computer thread, has no side effects, and returns zero in previews. Unknown face names return zero. Example: `ctx:endpoint("computed:redstone"):call("input", "left")`. + +## computed:redstone/comparator + +Signature: `(face: string) -> number`. Runs on the computer thread, has no side effects, and returns zero in previews. Unknown face names return zero. Example: `ctx:endpoint("computed:redstone"):call("comparator", "front")`. + +## computed:redstone/output + +Signature: `(face: string, level: number) -> ()`. Runs on the computer thread, clamps power to 0–15, performs a world side effect, and is unavailable in previews. Unknown face names are ignored. Example: `ctx:endpoint("computed:redstone"):call("output", "back", 15)`. + +## computed:command/run + +Signature: `(string) -> ()`. Runs on the computer thread, does not yield, performs a command side effect, and is unavailable in preview. Errors on a missing host, invalid argument, or command failure. Example: `ctx:endpoint("computed:command"):call("run", "say hello")`. + +## computed:widget/text + +Signature: `(string) -> table`. Runs on the computer thread, does not yield, has no world side effect, and uses the same deterministic fixture in preview. Returns `{ type = "text", text = value }`. Example: `ctx:output("widget", ctx:endpoint("computed:widget"):call("text", "Ready"))`. + +## computed:widget/clock + +Signature: `(color: number, showSeconds: boolean) -> table`. Runs on the computer thread, has no side effects, and has a deterministic preview fixture. Errors on invalid argument types. Example: `ctx:endpoint("computed:widget"):call("clock", 0xffffffff, true)`. + +## computed:widget/button + +Signature: `(label: string, color: number) -> table`. Runs on the computer thread, has no side effects, and has a deterministic preview fixture. The returned table carries the node instance ID for targeted input events. Example: `ctx:endpoint("computed:widget"):call("button", "Run", 0xffffffff)`. + +## computed:widget/slider + +Signature: `(value: number, minimum: number, maximum: number, color: number, step: number) -> table`. Runs on the computer thread, has no side effects, and has a deterministic preview fixture. Errors on invalid argument types. Example: `ctx:endpoint("computed:widget"):call("slider", 0.5, 0, 1, 0xffffffff, 0.01)`. + +## computed:widget/progress + +Signature: `(value: number, maximum: number, color: number, segments: number) -> table`. Runs on the computer thread, has no side effects, and has a deterministic preview fixture. Errors on invalid argument types. Example: `ctx:endpoint("computed:widget"):call("progress", 5, 10, 0xffffffff, 10)`. + +## computed:monitor/show + +Signature: `(widgets: table) -> ()`. Runs on the computer thread, refreshes an adjacent monitor, performs a world side effect, and is unavailable in previews. The endpoint target selects the computer-relative face. Invalid widget records are ignored. Example: `ctx:endpoint("computed:monitor", "front"):call("show", widgets)`. + +## create:kinetic/speed + +Signature: `() -> number`. The target is a computer-relative face. It runs on the computer/server tick thread, does not yield or mutate the world, and returns zero in preview. Production errors when Create is absent or the target is invalid; a non-kinetic block returns zero. Example: `ctx:endpoint("create:kinetic", "front"):call("speed")`. + +## create:kinetic/stress + +Signature: `() -> number`. It returns the adjacent Create block's applied stress units, runs on the computer/server tick thread, does not yield or mutate, and returns zero in preview. Missing Create and invalid targets are errors; non-kinetic blocks return zero. Example: `ctx:endpoint("create:kinetic", "left"):call("stress")`. + +## create:kinetic/capacity + +Signature: `() -> number`. It returns the adjacent Create block's generated stress capacity, runs on the computer/server tick thread, does not yield or mutate, and returns zero in preview. Missing Create and invalid targets are errors; non-kinetic blocks return zero. Example: `ctx:endpoint("create:kinetic", "right"):call("capacity")`. + +## create:redstone_link/receive + +Signature: `(firstItemId: string, secondItemId: string) -> number`. It registers a virtual Create redstone-link listener owned by the node and returns power from 0 through 15. It runs on the server tick thread, does not yield, retains an external network actor, and is unavailable in preview. Invalid item IDs, absent Create, or unavailable network APIs are errors or return zero. Actors are removed on definition replacement and unload. Example: `ctx:endpoint("create:redstone_link"):call("receive", "minecraft:iron_ingot", "minecraft:redstone")`. + +## create:redstone_link/transmit + +Signature: `(firstItemId: string, secondItemId: string, strength: number) -> ()`. It registers or updates a virtual Create transmitter, clamps strength to 0 through 15, performs a network side effect, runs on the server tick thread, does not yield, and is unavailable in preview. Invalid items or absent Create fail the invocation. Actors are removed on definition replacement and unload. Example: `ctx:endpoint("create:redstone_link"):call("transmit", "minecraft:iron_ingot", "minecraft:redstone", 15)`. + +## computercraft:channel/read + +Signature: `(channel: string) -> table`. It returns `{ value = ... }` for the named value written by an attached CC computer. Names contain 1 through 64 characters. It runs on the computer thread, does not yield or mutate the world, and returns an empty table fixture in preview. It errors when CC is absent or the host is not a server computer. Example: `ctx:endpoint("computercraft:channel"):call("read", "control")`. + +## computercraft:channel/publish + +Signature: `(channel: string, value: table) -> ()`. It publishes a named graph value and queues `computed_output_changed` on attached CC computers when the value changes. It runs on the computer thread, performs an integration side effect, does not yield, and is unavailable in preview. Unsupported or cyclic values and invalid channel names are errors. Example: `ctx:endpoint("computercraft:channel"):call("publish", "status", { ready = true })`. + +## computercraft:peripheral/methods + +Signature: `() -> table`. The endpoint target is a computer-relative side. It returns the adjacent peripheral's sorted public method names, runs on the server tick thread, does not yield or mutate, and is unavailable in preview. Missing CC, invalid sides, and detached peripherals are errors. Call it through the bound shorthand: `ctx:endpoint("computercraft:peripheral", "left"):methods()`. + +## computercraft:peripheral/call + +Signature: `(methodName: string, ...) -> table`. It invokes a public CC API method and returns all results as an integer-keyed table. It runs on the server tick thread, may perform peripheral side effects, and may yield for main-thread tasks, `MethodResult` continuations, or queued events. It is unavailable in preview. Missing methods, unsupported values, detach, unload, and failed continuations are errors; prior outputs remain committed while suspended. Example: `local result = ctx:endpoint("computercraft:peripheral", "left"):call("getEnergy")`. diff --git a/docs/lua/examples/addon-endpoint.java.txt b/docs/lua/examples/addon-endpoint.java.txt new file mode 100644 index 0000000..7ccf738 --- /dev/null +++ b/docs/lua/examples/addon-endpoint.java.txt @@ -0,0 +1,7 @@ +ComputedEndpoints.register("example:storage", endpoint -> endpoint.method( + "stored", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(LuaValue.valueOf(readStored(invocation.host()))), + invocation -> EndpointResult.immediate(LuaValue.valueOf(1000)), + "Returns stored units.")); diff --git a/docs/lua/examples/cc-yielded-call.lua b/docs/lua/examples/cc-yielded-call.lua new file mode 100644 index 0000000..8db8bf3 --- /dev/null +++ b/docs/lua/examples/cc-yielded-call.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "example:cc_yielded_call", "CC Yielded Call") + +node:category("integration/computercraft") +node:execution("tick") +node:field("side", "direction", { default = "left" }) +node:field("method", "text", { default = "getEnergy" }) +node:output("result", "table") +node:on_run(function(ctx) + local peripheral = ctx:endpoint("computercraft:peripheral", ctx:field("side")) + ctx:output("result", peripheral:call(ctx:field("method"))) +end) + +return node diff --git a/docs/lua/examples/event-counter.lua b/docs/lua/examples/event-counter.lua new file mode 100644 index 0000000..44060af --- /dev/null +++ b/docs/lua/examples/event-counter.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "example:event_counter", "Event Counter") + +node:category("state") +node:output("count", "number") +node:state("count", 0) +node:execution("event") +node:on_event("increment", function(ctx, amount) + local next = ctx:state("count") + amount + ctx:set_state("count", next) + ctx:output("count", next) +end) + +return node diff --git a/docs/lua/examples/math-scale.lua b/docs/lua/examples/math-scale.lua new file mode 100644 index 0000000..3393c0d --- /dev/null +++ b/docs/lua/examples/math-scale.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "example:math_scale", "Scale") + +node:category("math") +node:input("value", "number", { default = 0 }) +node:field("factor", "number", { default = 2 }) +node:output("result", "number") +node:on_run(function(ctx) + ctx:output("result", ctx:input("value") * ctx:field("factor")) +end) + +return node diff --git a/docs/lua/examples/runtime-error.lua b/docs/lua/examples/runtime-error.lua new file mode 100644 index 0000000..c0cf488 --- /dev/null +++ b/docs/lua/examples/runtime-error.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "example:guarded_divide", "Guarded Divide") + +node:category("math") +node:input("a", "number") +node:input("b", "number") +node:output("result", "number") +node:on_run(function(ctx) + if ctx:input("b") == 0 then + error("division by zero") + end + ctx:output("result", ctx:input("a") / ctx:input("b")) +end) + +return node diff --git a/docs/lua/examples/text-widget.lua b/docs/lua/examples/text-widget.lua new file mode 100644 index 0000000..50b31c9 --- /dev/null +++ b/docs/lua/examples/text-widget.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "example:text_widget", "Text Widget") + +node:category("widgets") +node:input("text", "string") +node:output("widget", "widget") +node:on_run(function(ctx) + local widgets = ctx:endpoint("computed:widget") + ctx:output("widget", widgets:call("text", ctx:input("text"))) +end) + +return node diff --git a/docs/lua/examples/world-time.lua b/docs/lua/examples/world-time.lua new file mode 100644 index 0000000..74a8dd7 --- /dev/null +++ b/docs/lua/examples/world-time.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "example:world_time", "World Time") + +node:category("world") +node:style("source") +node:output("time", "number") +node:execution("tick") +node:on_run(function(ctx) + ctx:output("time", ctx:endpoint("computed:world"):call("time")) +end) + +return node diff --git a/docs/lua/import-export.md b/docs/lua/import-export.md new file mode 100644 index 0000000..8a18460 --- /dev/null +++ b/docs/lua/import-export.md @@ -0,0 +1,11 @@ +# Import and Export + +The Lua editor exports readable source to the clipboard or `config/computed/nodes/_.lua`. Imports accept raw `.lua` source from those locations. + +For a new in-game definition, choose **User Nodes → New Lua Node…** in the Node Explorer. Applying the starter stores the source in the current computer and places the first instance. **Paste Lua** remains available from the empty-canvas context menu for clipboard imports. + +Server validation checks the 64 KiB source limit, API version, definition ID, schema, hash, permissions, and the 256 embedded-definition limit. An identical ID/hash is a no-op. The same ID with different source requires explicit confirmation. + +After replacement, instances recompile. Connections survive only when direction, stable port ID, and connection type all match. Removed or changed ports are reported before apply. + +CMP1, CMP2, format-2 graphs, JSON custom nodes, Functions, and Sections are not importable. Loading legacy world data initializes an empty format-3 program without backup. diff --git a/docs/lua/live-preview.md b/docs/lua/live-preview.md new file mode 100644 index 0000000..f1f362a --- /dev/null +++ b/docs/lua/live-preview.md @@ -0,0 +1,9 @@ +# Live Preview + +The focused Lua editor compiles 250 milliseconds after the last edit. Validation runs in order: syntax, definition contract, schema, then endpoint availability. + +The right pane uses the production semantic palette, padded content layout, and control-aware node sizing. Sample inputs and fields are editable. Running the preview advances isolated preview ticks and state; Reset recreates the node from its defaults. + +When source becomes invalid, the last valid preview remains visible, dimmed, and marked stale. Inline diagnostics describe the new invalid source. Preview endpoint calls use deterministic fixtures. Methods without fixtures, including command side effects, return an unavailable error and never touch a world. + +Applying source is separate from preview: the server recompiles it, checks permissions and size, and only then replaces the embedded definition. diff --git a/docs/lua/lua-api-reference.md b/docs/lua/lua-api-reference.md new file mode 100644 index 0000000..0e8acdb --- /dev/null +++ b/docs/lua/lua-api-reference.md @@ -0,0 +1,93 @@ +# Lua API Reference + +All methods execute on the computer thread. Definition methods have no side effects, are available during preview validation, return the same node for chaining, and fail when called after validation. + +## computed.node(apiVersion, id, title) + +Parameters: integer API version (`1`), namespaced definition ID, and display title. Returns a mutable definition builder. Errors on unsupported versions, invalid IDs, invalid titles, or a second node in the same file. Preview behavior is identical to production. Example: `local node = computed.node(1, "example:add", "Add")`. + +## node:category(name) + +Sets the stable semantic category or nested category path. The default is `utility`. Errors on blank or overlong names. It selects a named renderer palette and has no runtime side effect. Example: `node:category("math/arithmetic")`. + +## node:style(style) + +Sets `standard`, `compact`, `source`, or `sink`; the default is `standard`. Errors on unknown styles. Example: `node:style("compact")`. + +## node:input(id, type, options) + +Adds an input. `options` is optional; `required` defaults to `true` and `default` defaults to `nil`. Type is `number`, `boolean`, `string`, `event`, `widget`, or `table`. Errors on duplicate IDs or invalid values. Example: `node:input("value", "number", { default = 0 })`. + +## node:output(id, type, options) + +Adds an output with the same option and type rules as inputs. Outputs retain their last committed value after errors or yields. Example: `node:output("result", "number")`. + +## node:field(id, fieldType, options) + +Adds a `number`, `text`, `boolean`, `choice`, `color`, `direction`, or `item` field. Every field is rendered as an editable control inside each node instance. Options include `default`, an optional display `label`, numeric `min`/`max`, a positive numeric `step`, and `choices` for choice fields. `visible_when = { field = "mode", equals = "advanced" }` conditionally shows a field while preserving its value when hidden. Number fields default to `control = "value"`; `control = "slider"` requires finite `min` and `max` values with `max > min`. Text uses a value box, booleans use toggles, choice and direction fields use dropdowns, colors use ARGB hexadecimal controls, and items open the searchable item picker. Errors on invalid defaults, ranges, steps, controls, visibility references, or empty choice lists. Example: `node:field("gain", "number", { default = 1, min = 0, max = 4, control = "slider", step = 0.1, label = "Gain" })`. + +## node:state(id, defaultValue) + +Declares persistent state with a serializable default. Returns the node. Errors on duplicate IDs or unsupported values during validation/persistence. Example: `node:state("count", 0)`. + +## node:execution(policy) + +Sets `input`, `tick`, `step`, or `event`; the default is `input`. Returns the node and errors on an unknown policy. Example: `node:execution("tick")`. + +## node:on_run(callback) + +Installs the single run callback. Returns the node. Errors when declared twice or when the value is not a function. The callback runs transactionally. Example: `node:on_run(function(ctx) ctx:output("ok", true) end)`. + +## node:on_event(eventName, callback) + +Installs one named event callback. Returns the node. Errors on an invalid or duplicate event name. The callback receives `ctx` followed by emitted arguments. Example: `node:on_event("reset", function(ctx) ctx:set_state("count", 0) end)`. + +Context methods are available only inside callbacks. Reads have no side effects; writes stage changes until successful return. + +## ctx:input(id) + +Returns the current connected or default input. Errors on malformed calls. Preview returns the editable sample input. Example: `local speed = ctx:input("speed")`. + +## ctx:output(id, value) + +Stages an output and returns nothing. Errors if the value cannot later be serialized. Preview updates the real renderer sample. Example: `ctx:output("result", 4)`. + +## ctx:field(id) + +Returns the authoritative field or its default. Preview returns the editable sample field. Example: `local color = ctx:field("color")`. + +## ctx:state(id) + +Returns an isolated copy of committed state. Mutating a returned table does not commit it; use `set_state`. Example: `local count = ctx:state("count")`. + +## ctx:set_state(id, value) + +Stages persistent state and returns nothing. Errors on unknown or non-serializable state. It commits only after callback success. Example: `ctx:set_state("count", count + 1)`. + +## ctx:endpoint(id, target) + +Returns a safe endpoint proxy. `target` is optional and defaults to an empty target. Errors on unknown endpoints. Preview uses fixtures or reports unavailable; it never performs production side effects. Example: `local world = ctx:endpoint("computed:world")`. + +## ctx:emit(eventName, ...) + +Queues a named graph event with serializable arguments and returns nothing. Delivery is deterministic after the current graph pass. Preview delivers inside the isolated preview graph. Example: `ctx:emit("changed", 12)`. + +## ctx:tick() + +Returns the current non-negative computer tick as an integer. It has no side effects and preview uses the preview tick. Example: `local now = ctx:tick()`. + +## ctx:graph_step() + +Returns the current deterministic graph step as an integer. It has no side effects. Example: `local order = ctx:graph_step()`. + +## ctx:is_preview() + +Returns `true` only in the isolated live preview. It has no side effects. Production logic should prefer endpoint policies over branching on this value. Example: `if ctx:is_preview() then ... end`. + +## endpoint:methods() + +Returns an alphabetically stable array of method IDs. Bound dynamic endpoints such as `computercraft:peripheral` instead return methods exposed by the selected target. It has no side effects. Static endpoint metadata is available in preview; dynamic target discovery may be unavailable. Example: `for _, name in ipairs(endpoint:methods()) do ... end`. + +## endpoint:call(methodName, ...) + +Validates arguments and invokes the registered handler. It returns the declared values, may yield only when the policy permits, and errors on unavailable previews, signature mismatches, handler failures, or invalid return values. Side effects and execution side are method-specific. Example: `local time = world:call("time")`. diff --git a/docs/lua/sandbox-and-budgets.md b/docs/lua/sandbox-and-budgets.md new file mode 100644 index 0000000..73f3d68 --- /dev/null +++ b/docs/lua/sandbox-and-budgets.md @@ -0,0 +1,9 @@ +# Sandbox and Execution Budgets + +Each computer owns one Lua VM. Every node instance receives an isolated environment and state map. Compiled prototypes are globally cached by API version and SHA-256 source hash. + +Available libraries are base primitives, `math`, `string`, `table`, `bit32`, and `coroutine`. The runtime does not expose `io`, `os`, `debug`, `package`, `require`, `load`, `loadfile`, `dofile`, or `luajava`. Java reflection and arbitrary Java objects are unreachable. + +Each node invocation is limited to 50,000 Lua instructions. Each computer is limited to 500,000 Lua instructions per game tick. The private hook cannot be read or replaced by Lua. Limit errors abort the offending invocation, retain committed values, add an inline runtime diagnostic, and permit a later policy-driven retry. + +Unloading a computer or replacing/removing a definition cancels its yielded coroutines. diff --git a/docs/lua/types-and-state.md b/docs/lua/types-and-state.md new file mode 100644 index 0000000..458d9ae --- /dev/null +++ b/docs/lua/types-and-state.md @@ -0,0 +1,9 @@ +# Types and State Serialization + +Connection types are `number`, `boolean`, `string`, `event`, `widget`, and `table`. Connections require exactly matching types and each input accepts at most one edge. + +Persistent values support Lua `nil`, booleans, finite numbers, strings, and acyclic tables. Table keys must be strings or integers. The maximum table depth is 16 and all program data shares the four-megabyte limit. + +Functions, userdata, threads, cyclic tables, non-finite numbers, fractional numeric keys, and unsupported Java values are rejected. A serialization failure fails only the invocation and preserves the last committed state and outputs. + +State is copied into an invocation. `ctx:set_state` stages a replacement; successful callback return commits the entire staged map. Yielded callbacks keep staged changes private until resumption completes. diff --git a/docs/node-api.md b/docs/node-api.md deleted file mode 100644 index 53243cb..0000000 --- a/docs/node-api.md +++ /dev/null @@ -1,74 +0,0 @@ -# Computed node API - -Computed exposes its node declaration API from `dev.propulsionteam.computed.api.node`. Addons register -node types and palette categories during common startup, before Computed freezes the registry. Optional -custom editor presentations are registered from client startup through the separate -`dev.propulsionteam.computed.api.node.client` package. - -See [`docs/example-addon`](example-addon/README.md) for a complete stateful node and client -presentation registration example. - -## Declaring a node - -Define port and property keys as constants. Their lowercase IDs are persistence identifiers, so never -derive them from translated labels and never reuse an ID for a different type. - -```java -public static final PortKey INPUT = PortKey.of("input", PortType.NUMBER); -public static final PortKey OUTPUT = PortKey.of("output", PortType.NUMBER); -public static final NodeProperty SCALE = - NodeProperty.number("scale", Component.literal("Scale"), 1.0D); -``` - -Build a `NodeType` with its identity, schema, properties, state codec and evaluator. The evaluator -receives the immutable state from the beginning of the graph step, writes typed outputs through the -context, and returns the next state. The runtime commits returned states only after the step. - -Use `stateBoundary(true)` when the node's prior state breaks combinational dependency cycles. Choose an -execution policy deliberately: - -- `INPUT_DRIVEN` runs after an input or property changes. -- `EVERY_GAME_TICK` runs once per Minecraft tick. -- `EVERY_GRAPH_STEP` runs on every scheduler step. - -World access is available only through `NodeExecutionContext`. Side-effecting nodes should use -`runSideEffect`; it does nothing during client previews or any evaluation where effects are suppressed. - -## Dynamic schemas - -Pass a `NodeSchemaFactory` instead of a fixed `NodeSchema` when properties determine the ports. Build -dynamic IDs from stable property values—for example `widget_0`, `widget_1`, and so on. Existing IDs must -not be renumbered merely because labels or translations change. - -```java -.property(INPUT_COUNT) -.schema(properties -> { - NodeSchema.Builder schema = NodeSchema.builder(); - for (int i = 0; i < properties.get(INPUT_COUNT); i++) { - schema.input(PortKey.of("widget_" + i, PortType.WIDGET), Component.literal("Widget " + (i + 1))); - } - return schema.build(); -}) -``` - -The built-in neutral values are `0.0` for numbers, the empty string for text, and `null` for the opaque -no-widget value. Runtime validation accepts identical types and the documented number-to-string -conversion; it rejects other mismatches. - -## Registration lifecycle - -Register parent categories before child categories, then register node types. Duplicate IDs throw an -actionable startup error. Computed calls `ComputedNodeApi.freeze()` after addon registration; all later -common registrations are rejected. The client presentation registry has the same duplicate and freeze -behavior. - -Custom presentations are optional. With no registration, the editor builds controls from the node's -typed property definitions. Client presentation code must stay in a client-only package and should only -be invoked from the loader's client initialization path. - -## Persistence compatibility - -`ResourceLocation` node IDs, `PortKey` IDs, property keys, and codec shapes are saved data. Changing any -of them requires an explicit migration. Add new ports and properties with stable defaults, preserve old -keys when practical, and treat state records as immutable values rather than mutating the prior state in -place. diff --git a/docs/node-migration.md b/docs/node-migration.md deleted file mode 100644 index 05afd14..0000000 --- a/docs/node-migration.md +++ /dev/null @@ -1,54 +0,0 @@ -# Migrating addons from Web's Node Lib - -Computed no longer exports or embeds `dev.devce.websnodelib`. This is an intentional source- and -binary-incompatible break that prevents the Java module split-package conflict with Aeronautics. An -addon compiled against `WNode`, `WGraph`, or any other Web's Node Lib type must be ported and rebuilt. -There is no compatibility facade. - -## Registration - -- Replace `NodeRegistry.register(...)` with `ComputedNodeApi.register(NodeType)`. -- Register palette groups with `ComputedNodeApi.registerCategory(...)` before registering children. -- Perform common registration during mod startup, before Computed freezes the registry. -- Move editor-only code to a client-only package and register it with - `ComputedNodeClientApi.registerPresentation(...)`. -- Treat duplicate IDs as startup errors. Do not catch and ignore them. - -The complete registration lifecycle and a buildable source example are documented in -[`node-api.md`](node-api.md) and [`example-addon`](example-addon/README.md). - -## Node declarations - -| Old concept | Computed API | -| --- | --- | -| `WNode` subclass | `NodeType` built with `NodeType.builder(...)` | -| positional `WPin` | stable `PortKey` in a `NodeSchema` | -| pin value reads/writes | typed `NodeExecutionContext.input(...)` and `output(...)` | -| mutable fields saved by the node | immutable state `S` plus a state codec | -| element-backed settings | typed `NodeProperty` entries | -| pins rebuilt from controls | `NodeSchemaFactory` driven by properties | -| `evaluate(...)` | `NodeExecutor` returning the next state | -| editor rendering in the node class | optional client `NodePresentation` | - -Keep node IDs, port-key IDs, property keys, and state codec fields stable after release. Labels and -translations may change; persistence identifiers must not. When a property changes a dynamic schema, -retain existing keys and append new keys instead of renumbering ports. - -Use `stateBoundary(true)` for latches, delays, counters, and other memory nodes. Their outputs expose -prior-step state; returned state is committed after the graph step. World access and side effects must -go through `NodeExecutionContext`, which suppresses effects in client preview. - -## Saved-program migration - -Computed accepts the old `ComputerGraph` and `ComputerFunctions` NBT layout, legacy function and -clipboard fragments, Base64/SNBT imports, and `CMP1` share strings. Built-in IDs in the -`websnodelib:*` namespace are canonicalized to `computed:*`. The first successful save writes only the -version-2 `ComputedProgram` layout, and share export emits `CMP2`. - -This migration is one-way. A world saved by the rewritten engine is not supported by older Computed -releases. Back up a world before upgrading if downgrade support matters. - -Unknown addon nodes are retained as disabled placeholders with their raw type, properties, state, -ports, and connections. Reinstalling an addon that registers the original node ID allows the program -to resolve it again. Malformed connections and legacy combinational cycles remain visible as -diagnostics so users can repair them rather than losing content. diff --git a/gradle.properties b/gradle.properties index ec7ac38..0e081e8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,6 +22,7 @@ neo_version=21.1.228 create_version=6.0.10-280 ponder_version=1.0.82 flywheel_version=1.0.6 +cct_version=1.120.0 # The loader version range can only use the major version of FML as bounds loader_version_range=[1,) @@ -39,4 +40,4 @@ mod_version=1.0.0 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html -mod_group_id=dev.propulsionteam.computed \ No newline at end of file +mod_group_id=dev.propulsionteam.computed diff --git a/src/main/java/dev/propulsionteam/computed/Computed.java b/src/main/java/dev/propulsionteam/computed/Computed.java index 09d6f32..b3071f1 100644 --- a/src/main/java/dev/propulsionteam/computed/Computed.java +++ b/src/main/java/dev/propulsionteam/computed/Computed.java @@ -8,11 +8,10 @@ import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; import net.neoforged.fml.config.ModConfig; -import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; - -import dev.propulsionteam.computed.internal.node.ComputedNodeSystem; -import dev.propulsionteam.computed.content.ComputedNodes; import dev.propulsionteam.computed.content.ComputedRegistries; +import dev.propulsionteam.computed.integration.computercraft.ComputerCraftBootstrap; +import dev.propulsionteam.computed.integration.create.CreateIntegration; +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpoints; import dev.propulsionteam.computed.network.ComputedNetworking; @Mod(Computed.MODID) @@ -21,22 +20,11 @@ public class Computed { public static final Logger LOGGER = LogUtils.getLogger(); public Computed(IEventBus modEventBus, ModContainer modContainer) { - ComputedNodeSystem.bootstrap(); - ComputedNodes.register(); - + BuiltinEndpoints.register(); + CreateIntegration.register(); ComputedRegistries.register(modEventBus); + ComputerCraftBootstrap.register(modEventBus); ComputedNetworking.register(modEventBus); - - modEventBus.addListener(this::commonSetup); - modContainer.registerConfig(ModConfig.Type.COMMON, Config.SPEC); } - - private void commonSetup(FMLCommonSetupEvent event) { - event.enqueueWork(() -> { - ComputedNodeSystem.finalizeRegistrations(); - LOGGER.info("Computed node registry frozen with {} public node types", - dev.propulsionteam.computed.api.node.ComputedNodeApi.nodeTypes().size()); - }); - } } diff --git a/src/main/java/dev/propulsionteam/computed/ComputedClient.java b/src/main/java/dev/propulsionteam/computed/ComputedClient.java index 5f9c55b..56434f4 100644 --- a/src/main/java/dev/propulsionteam/computed/ComputedClient.java +++ b/src/main/java/dev/propulsionteam/computed/ComputedClient.java @@ -1,20 +1,12 @@ package dev.propulsionteam.computed; -import dev.propulsionteam.computed.internal.node.api.FunctionCardNode; -import dev.propulsionteam.computed.internal.node.api.FunctionDefinitionStore; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.ProgramBridge; import dev.propulsionteam.computed.client.ComputerEditorScreen; import dev.propulsionteam.computed.client.ComputerPeripheralScreen; -import dev.propulsionteam.computed.client.ComputedClientCommands; import dev.propulsionteam.computed.content.ComputedRegistries; import dev.propulsionteam.computed.content.Peripherals; import dev.propulsionteam.computed.menu.ComputerPeripheralMenu; -import java.util.HashSet; -import java.util.Set; import net.minecraft.client.Minecraft; -import net.minecraft.nbt.Tag; -import net.minecraft.resources.ResourceLocation; +import dev.propulsionteam.computed.persistence.ProgramV3Codec; import net.minecraft.world.inventory.MenuType; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.SubscribeEvent; @@ -23,38 +15,23 @@ import net.neoforged.fml.common.Mod; import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; import net.neoforged.neoforge.client.event.EntityRenderersEvent; -import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent; import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent; import dev.propulsionteam.computed.client.MonitorBlockEntityRenderer; import net.neoforged.neoforge.client.gui.ConfigurationScreen; import net.neoforged.neoforge.client.gui.IConfigScreenFactory; -import net.neoforged.neoforge.common.NeoForge; @Mod(value = Computed.MODID, dist = Dist.CLIENT) @EventBusSubscriber(modid = Computed.MODID, value = Dist.CLIENT) public class ComputedClient { static { ComputerEditorBridge.install((pos, serverRevision, tag) -> { - ProgramBridge.RuntimeProgram runtime = ProgramBridge.decode(tag); - WGraph graph = runtime.graph(); - FunctionDefinitionStore functions = runtime.functions(); - FunctionCardNode.applyLibraryToInnerGraphs(graph, functions); - Set unlock = new HashSet<>(); - if (tag.contains(Peripherals.NBT_EDITOR_PERIPHERAL_UNLOCK, Tag.TAG_LIST)) { - for (Tag t : tag.getList(Peripherals.NBT_EDITOR_PERIPHERAL_UNLOCK, Tag.TAG_STRING)) { - unlock.add(ResourceLocation.parse(t.getAsString())); - } - } + var program = ProgramV3Codec.decode(tag, pos.toShortString(), Computed.LOGGER::warn).program(); Minecraft.getInstance() .setScreen( new ComputerEditorScreen( pos, - graph, - functions, - unlock, - Peripherals.readPlacedPeripheralHudLines(tag), - serverRevision, - runtime.program())); + program, + serverRevision)); }, (pos, accepted, serverRevision, editorRevision, message) -> { if (Minecraft.getInstance().screen instanceof ComputerEditorScreen screen && screen.editsComputer(pos)) { @@ -65,24 +42,11 @@ public class ComputedClient { public ComputedClient(ModContainer container) { container.registerExtensionPoint(IConfigScreenFactory.class, ConfigurationScreen::new); - NeoForge.EVENT_BUS.addListener(ComputedClient::onRegisterClientCommands); - NeoForge.EVENT_BUS.addListener(ComputedClient::onLoggingOut); - } - - /** A server may have replaced our custom nodes with its own; restore the local config nodes after disconnect. */ - private static void onLoggingOut(net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) { - dev.propulsionteam.computed.customnodes.ComputedCustomNodes.reload(); - } - - private static void onRegisterClientCommands(RegisterClientCommandsEvent event) { - dev.propulsionteam.computed.internal.node.internal.ComputedNodeCommands.register(event.getDispatcher()); - ComputedClientCommands.register(event.getDispatcher()); } @SubscribeEvent static void onClientSetup(FMLClientSetupEvent event) { Computed.LOGGER.info("Computed client setup"); - event.enqueueWork(dev.propulsionteam.computed.api.node.client.ComputedNodeClientApi::freeze); } @SubscribeEvent diff --git a/src/main/java/dev/propulsionteam/computed/api/node/ComputedNodeApi.java b/src/main/java/dev/propulsionteam/computed/api/node/ComputedNodeApi.java deleted file mode 100644 index 6684b2d..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/ComputedNodeApi.java +++ /dev/null @@ -1,129 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.ArrayDeque; -import java.util.Collections; -import java.util.Deque; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -/** Startup registry for public node types and palette categories. */ -public final class ComputedNodeApi { - public static final ResourceLocation ROOT_CATEGORY = - ResourceLocation.fromNamespaceAndPath("computed", "root"); - public static final ResourceLocation UNCATEGORIZED_CATEGORY = - ResourceLocation.fromNamespaceAndPath("computed", "uncategorized"); - - private static final LinkedHashMap> NODE_TYPES = new LinkedHashMap<>(); - private static final LinkedHashMap CATEGORIES = new LinkedHashMap<>(); - private static volatile boolean frozen; - - static { - CATEGORIES.put(ROOT_CATEGORY, NodeCategory.root(ROOT_CATEGORY, Component.literal("Nodes"))); - CATEGORIES.put( - UNCATEGORIZED_CATEGORY, - NodeCategory.child(UNCATEGORIZED_CATEGORY, Component.literal("Uncategorized"), ROOT_CATEGORY)); - } - - private ComputedNodeApi() {} - - public static synchronized NodeType register(NodeType type) { - ensureMutable(); - Objects.requireNonNull(type, "type"); - NodeType previous = NODE_TYPES.putIfAbsent(type.id(), type); - if (previous != null) { - throw new IllegalStateException( - "Cannot register node type '" + type.id() + "': that id is already registered as '" - + previous.title().getString() + "'"); - } - return type; - } - - public static synchronized NodeCategory registerCategory(NodeCategory category) { - ensureMutable(); - Objects.requireNonNull(category, "category"); - NodeCategory previous = CATEGORIES.putIfAbsent(category.id(), category); - if (previous != null) { - throw new IllegalStateException( - "Cannot register node category '" + category.id() + "': that id is already registered as '" - + previous.title().getString() + "'"); - } - return category; - } - - public static NodeCategory registerCategory( - ResourceLocation id, Component title, ResourceLocation parentId) { - return registerCategory(NodeCategory.child(id, title, parentId)); - } - - public static synchronized Optional> nodeType(ResourceLocation id) { - return Optional.ofNullable(NODE_TYPES.get(Objects.requireNonNull(id, "id"))); - } - - public static synchronized NodeType requireNodeType(ResourceLocation id) { - return nodeType(id).orElseThrow(() -> new IllegalArgumentException("Unknown node type '" + id + "'")); - } - - public static synchronized Optional category(ResourceLocation id) { - return Optional.ofNullable(CATEGORIES.get(Objects.requireNonNull(id, "id"))); - } - - public static synchronized Map> nodeTypes() { - return Collections.unmodifiableMap(new LinkedHashMap<>(NODE_TYPES)); - } - - public static synchronized Map categories() { - return Collections.unmodifiableMap(new LinkedHashMap<>(CATEGORIES)); - } - - /** Validates category references and permanently closes both registries. */ - public static synchronized void freeze() { - if (frozen) { - return; - } - validateCategories(); - for (NodeType type : NODE_TYPES.values()) { - if (!CATEGORIES.containsKey(type.category())) { - throw new IllegalStateException( - "Node type '" + type.id() + "' references unknown category '" + type.category() + "'"); - } - } - frozen = true; - } - - public static boolean isFrozen() { - return frozen; - } - - private static void validateCategories() { - for (NodeCategory category : CATEGORIES.values()) { - category.parentId().ifPresent(parent -> { - if (!CATEGORIES.containsKey(parent)) { - throw new IllegalStateException( - "Node category '" + category.id() + "' references unknown parent '" + parent + "'"); - } - }); - } - - for (ResourceLocation start : CATEGORIES.keySet()) { - Deque path = new ArrayDeque<>(); - ResourceLocation current = start; - while (current != null) { - if (path.contains(current)) { - throw new IllegalStateException("Node category cycle detected: " + path + " -> " + current); - } - path.addLast(current); - current = CATEGORIES.get(current).parentId().orElse(null); - } - } - } - - private static void ensureMutable() { - if (frozen) { - throw new IllegalStateException("Computed node registry is frozen; registrations must occur during startup"); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/DiagnosticSeverity.java b/src/main/java/dev/propulsionteam/computed/api/node/DiagnosticSeverity.java deleted file mode 100644 index 079545d..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/DiagnosticSeverity.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -/** User-visible importance of a node or graph diagnostic. */ -public enum DiagnosticSeverity { - INFO, - WARNING, - ERROR -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/DiagnosticSink.java b/src/main/java/dev/propulsionteam/computed/api/node/DiagnosticSink.java deleted file mode 100644 index 1eff894..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/DiagnosticSink.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -/** Receives diagnostics from graph compilation, migration, and node execution. */ -@FunctionalInterface -public interface DiagnosticSink { - DiagnosticSink NONE = ignored -> {}; - - void report(NodeDiagnostic diagnostic); -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/ExecutionPolicy.java b/src/main/java/dev/propulsionteam/computed/api/node/ExecutionPolicy.java deleted file mode 100644 index 61077c9..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/ExecutionPolicy.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -/** Determines when a compiled graph schedules a node for evaluation. */ -public enum ExecutionPolicy { - /** Evaluate when an input value or property changes. */ - INPUT_DRIVEN, - /** Evaluate once for each Minecraft game tick. */ - EVERY_GAME_TICK, - /** Evaluate during every graph step, including multiple steps in one game tick. */ - EVERY_GRAPH_STEP -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodeCategory.java b/src/main/java/dev/propulsionteam/computed/api/node/NodeCategory.java deleted file mode 100644 index 9f72ca7..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodeCategory.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.Objects; -import java.util.Optional; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -/** One folder in the editor's hierarchical node palette. */ -public record NodeCategory(ResourceLocation id, Component title, Optional parentId) { - public NodeCategory { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(title, "title"); - parentId = Objects.requireNonNull(parentId, "parentId"); - } - - public static NodeCategory root(ResourceLocation id, Component title) { - return new NodeCategory(id, title, Optional.empty()); - } - - public static NodeCategory child(ResourceLocation id, Component title, ResourceLocation parentId) { - return new NodeCategory(id, title, Optional.of(Objects.requireNonNull(parentId, "parentId"))); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodeDiagnostic.java b/src/main/java/dev/propulsionteam/computed/api/node/NodeDiagnostic.java deleted file mode 100644 index 45436eb..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodeDiagnostic.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.regex.Pattern; -import net.minecraft.network.chat.Component; - -/** Structured diagnostic that can be attached to a graph node and, optionally, one of its ports. */ -public record NodeDiagnostic( - DiagnosticSeverity severity, - String code, - Component message, - Optional nodeId, - Optional portId) { - private static final Pattern VALID_CODE = Pattern.compile("[a-z][a-z0-9_.-]*"); - - public NodeDiagnostic { - Objects.requireNonNull(severity, "severity"); - if (code == null || !VALID_CODE.matcher(code).matches()) { - throw new IllegalArgumentException("Diagnostic code must match " + VALID_CODE.pattern()); - } - Objects.requireNonNull(message, "message"); - nodeId = Objects.requireNonNull(nodeId, "nodeId"); - portId = Objects.requireNonNull(portId, "portId"); - } - - public static NodeDiagnostic info(String code, Component message) { - return create(DiagnosticSeverity.INFO, code, message); - } - - public static NodeDiagnostic warning(String code, Component message) { - return create(DiagnosticSeverity.WARNING, code, message); - } - - public static NodeDiagnostic error(String code, Component message) { - return create(DiagnosticSeverity.ERROR, code, message); - } - - public static NodeDiagnostic create(DiagnosticSeverity severity, String code, Component message) { - return new NodeDiagnostic(severity, code, message, Optional.empty(), Optional.empty()); - } - - public NodeDiagnostic forNode(UUID nodeId) { - return new NodeDiagnostic(severity, code, message, Optional.of(nodeId), portId); - } - - public NodeDiagnostic atPort(String portId) { - if (portId == null || portId.isBlank()) { - throw new IllegalArgumentException("portId must not be blank"); - } - return new NodeDiagnostic(severity, code, message, nodeId, Optional.of(portId)); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodeExecutionContext.java b/src/main/java/dev/propulsionteam/computed/api/node/NodeExecutionContext.java deleted file mode 100644 index 5c7a106..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodeExecutionContext.java +++ /dev/null @@ -1,56 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.Objects; -import java.util.Optional; -import java.util.function.Consumer; -import net.minecraft.core.BlockPos; -import net.minecraft.server.level.ServerLevel; - -/** Runtime services and typed I/O for one node evaluation. */ -public interface NodeExecutionContext { - NodePropertyBag properties(); - - T input(PortKey key); - - void output(PortKey key, T value); - - boolean isInputConnected(PortKey key); - - long gameTick(); - - long graphStep(); - - /** True while the client is evaluating a read-only editor preview. */ - boolean isPreview(); - - /** Empty during client previews and other evaluations that have no server world. */ - Optional level(); - - Optional origin(); - - /** False for previews and whenever runtime side effects are intentionally suppressed. */ - boolean sideEffectsAllowed(); - - DiagnosticSink diagnostics(); - - default void report(NodeDiagnostic diagnostic) { - diagnostics().report(Objects.requireNonNull(diagnostic, "diagnostic")); - } - - /** - * Runs a world mutation only when the runtime permits side effects. This is the preferred way for - * addon nodes to perform effects because previews cannot accidentally invoke the action. - */ - default boolean runSideEffect(Consumer action) { - Objects.requireNonNull(action, "action"); - if (!sideEffectsAllowed()) { - return false; - } - Optional level = level(); - if (level.isEmpty()) { - return false; - } - action.accept(level.get()); - return true; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodeExecutor.java b/src/main/java/dev/propulsionteam/computed/api/node/NodeExecutor.java deleted file mode 100644 index 685d53c..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodeExecutor.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -/** Evaluates a node using immutable prior state and returns the state committed after the graph step. */ -@FunctionalInterface -public interface NodeExecutor { - S execute(S priorState, NodeExecutionContext context) throws Exception; -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodeProperty.java b/src/main/java/dev/propulsionteam/computed/api/node/NodeProperty.java deleted file mode 100644 index 3144437..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodeProperty.java +++ /dev/null @@ -1,145 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import com.mojang.serialization.Codec; -import java.util.Objects; -import java.util.function.Predicate; -import java.util.function.Supplier; -import java.util.regex.Pattern; -import net.minecraft.network.chat.Component; - -/** A typed, persistable property definition used by node schemas and generic editor controls. */ -public final class NodeProperty { - private static final Pattern VALID_KEY = Pattern.compile("[a-z][a-z0-9_.-]*"); - - private final String key; - private final Component title; - private final Class valueClass; - private final Codec codec; - private final Supplier defaultFactory; - private final Predicate validator; - private final String validationMessage; - - private NodeProperty(Builder builder) { - key = builder.key; - title = builder.title; - valueClass = builder.valueClass; - codec = builder.codec; - defaultFactory = builder.defaultFactory; - validator = builder.validator; - validationMessage = builder.validationMessage; - validate(defaultValue()); - } - - public static Builder builder( - String key, Component title, Class valueClass, Codec codec) { - return new Builder<>(key, title, valueClass, codec); - } - - public static NodeProperty string(String key, Component title, String defaultValue) { - return builder(key, title, String.class, Codec.STRING).defaultValue(defaultValue).build(); - } - - public static NodeProperty number(String key, Component title, double defaultValue) { - return builder(key, title, Double.class, Codec.DOUBLE).defaultValue(defaultValue).build(); - } - - public static NodeProperty integer(String key, Component title, int defaultValue) { - return builder(key, title, Integer.class, Codec.INT).defaultValue(defaultValue).build(); - } - - public static NodeProperty bool(String key, Component title, boolean defaultValue) { - return builder(key, title, Boolean.class, Codec.BOOL).defaultValue(defaultValue).build(); - } - - public String key() { - return key; - } - - public Component title() { - return title; - } - - public Class valueClass() { - return valueClass; - } - - public Codec codec() { - return codec; - } - - /** Returns a fresh default when the definition supplied a factory. */ - public T defaultValue() { - return Objects.requireNonNull(defaultFactory.get(), "Default value for property '" + key + "'"); - } - - public boolean isValid(T value) { - return value != null && valueClass.isInstance(value) && validator.test(value); - } - - public void validate(T value) { - if (!isValid(value)) { - throw new IllegalArgumentException("Invalid value for property '" + key + "': " + validationMessage); - } - } - - T castAndValidate(Object value) { - if (!valueClass.isInstance(value)) { - throw new IllegalArgumentException( - "Property '" + key + "' requires " + valueClass.getSimpleName() + ", got " - + (value == null ? "null" : value.getClass().getSimpleName())); - } - T typed = valueClass.cast(value); - validate(typed); - return typed; - } - - @Override - public String toString() { - return key; - } - - public static final class Builder { - private final String key; - private final Component title; - private final Class valueClass; - private final Codec codec; - private Supplier defaultFactory; - private Predicate validator = ignored -> true; - private String validationMessage = "value failed validation"; - - private Builder(String key, Component title, Class valueClass, Codec codec) { - if (key == null || !VALID_KEY.matcher(key).matches()) { - throw new IllegalArgumentException( - "Property key must match " + VALID_KEY.pattern() + ", got: " + key); - } - this.key = key; - this.title = Objects.requireNonNull(title, "title"); - this.valueClass = Objects.requireNonNull(valueClass, "valueClass"); - this.codec = Objects.requireNonNull(codec, "codec"); - } - - public Builder defaultValue(T value) { - Objects.requireNonNull(value, "value"); - defaultFactory = () -> value; - return this; - } - - public Builder defaultFactory(Supplier factory) { - defaultFactory = Objects.requireNonNull(factory, "factory"); - return this; - } - - public Builder validator(Predicate validator, String message) { - this.validator = Objects.requireNonNull(validator, "validator"); - validationMessage = Objects.requireNonNull(message, "message"); - return this; - } - - public NodeProperty build() { - if (defaultFactory == null) { - throw new IllegalStateException("Property '" + key + "' has no default value"); - } - return new NodeProperty<>(this); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodePropertyBag.java b/src/main/java/dev/propulsionteam/computed/api/node/NodePropertyBag.java deleted file mode 100644 index cdf3c6c..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodePropertyBag.java +++ /dev/null @@ -1,101 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; - -/** Immutable values for a declared set of typed node properties. */ -public final class NodePropertyBag { - private static final NodePropertyBag EMPTY = - new NodePropertyBag(new LinkedHashMap<>(), new LinkedHashMap<>()); - - private final Map> definitions; - private final Map values; - - private NodePropertyBag( - LinkedHashMap> definitions, LinkedHashMap values) { - this.definitions = Collections.unmodifiableMap(new LinkedHashMap<>(definitions)); - this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); - } - - public static NodePropertyBag empty() { - return EMPTY; - } - - public static Builder builder(Collection> definitions) { - return new Builder(definitions); - } - - public static NodePropertyBag defaults(Collection> definitions) { - return builder(definitions).build(); - } - - public List> definitions() { - return List.copyOf(definitions.values()); - } - - public Optional> definition(String key) { - return Optional.ofNullable(definitions.get(key)); - } - - public T get(NodeProperty property) { - requireDefinition(property); - return property.castAndValidate(values.get(property.key())); - } - - public NodePropertyBag with(NodeProperty property, T value) { - requireDefinition(property); - LinkedHashMap copy = new LinkedHashMap<>(values); - copy.put(property.key(), property.castAndValidate(value)); - return new NodePropertyBag(new LinkedHashMap<>(definitions), copy); - } - - /** A read-only persistence-oriented view keyed by stable property keys. */ - public Map values() { - return values; - } - - private void requireDefinition(NodeProperty property) { - Objects.requireNonNull(property, "property"); - NodeProperty declared = definitions.get(property.key()); - if (declared != property) { - throw new IllegalArgumentException("Property '" + property.key() + "' is not declared by this bag"); - } - } - - public static final class Builder { - private final LinkedHashMap> definitions = new LinkedHashMap<>(); - private final LinkedHashMap values = new LinkedHashMap<>(); - - private Builder(Collection> suppliedDefinitions) { - Objects.requireNonNull(suppliedDefinitions, "definitions"); - for (NodeProperty property : suppliedDefinitions) { - Objects.requireNonNull(property, "property"); - if (definitions.putIfAbsent(property.key(), property) != null) { - throw new IllegalArgumentException("Duplicate property key '" + property.key() + "'"); - } - values.put(property.key(), property.defaultValue()); - } - } - - public Builder set(NodeProperty property, T value) { - Objects.requireNonNull(property, "property"); - if (definitions.get(property.key()) != property) { - throw new IllegalArgumentException("Property '" + property.key() + "' is not declared by this bag"); - } - values.put(property.key(), property.castAndValidate(value)); - return this; - } - - public NodePropertyBag build() { - return definitions.isEmpty() - ? EMPTY - : new NodePropertyBag(definitions, values); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodeSchema.java b/src/main/java/dev/propulsionteam/computed/api/node/NodeSchema.java deleted file mode 100644 index 2639560..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodeSchema.java +++ /dev/null @@ -1,103 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.minecraft.network.chat.Component; - -/** Immutable, ordered set of the ports exposed by a node instance. */ -public final class NodeSchema { - private static final NodeSchema EMPTY = new NodeSchema(List.of()); - - private final List> ports; - private final List> inputs; - private final List> outputs; - private final Map> byId; - - private NodeSchema(List> definitions) { - LinkedHashMap> indexed = new LinkedHashMap<>(); - ArrayList> inputList = new ArrayList<>(); - ArrayList> outputList = new ArrayList<>(); - for (PortDefinition definition : definitions) { - Objects.requireNonNull(definition, "port definition"); - PortDefinition previous = indexed.putIfAbsent(definition.key().id(), definition); - if (previous != null) { - throw new IllegalArgumentException("Duplicate port key '" + definition.key().id() + "'"); - } - (definition.direction() == PortDirection.INPUT ? inputList : outputList).add(definition); - } - ports = List.copyOf(indexed.values()); - inputs = List.copyOf(inputList); - outputs = List.copyOf(outputList); - byId = Collections.unmodifiableMap(indexed); - } - - public static NodeSchema empty() { - return EMPTY; - } - - public static Builder builder() { - return new Builder(); - } - - public List> ports() { - return ports; - } - - public List> inputs() { - return inputs; - } - - public List> outputs() { - return outputs; - } - - public Optional> port(String id) { - return Optional.ofNullable(byId.get(id)); - } - - public Optional> port(PortKey key) { - PortDefinition definition = byId.get(Objects.requireNonNull(key, "key").id()); - if (definition == null || !definition.key().equals(key)) { - return Optional.empty(); - } - @SuppressWarnings("unchecked") - PortDefinition typed = (PortDefinition) definition; - return Optional.of(typed); - } - - public PortDefinition requirePort(String id) { - PortDefinition definition = byId.get(id); - if (definition == null) { - throw new IllegalArgumentException("Unknown port key '" + id + "'"); - } - return definition; - } - - public static final class Builder { - private final List> definitions = new ArrayList<>(); - - private Builder() {} - - public Builder port(PortDefinition definition) { - definitions.add(Objects.requireNonNull(definition, "definition")); - return this; - } - - public Builder input(PortKey key, Component label) { - return port(PortDefinition.input(key, label)); - } - - public Builder output(PortKey key, Component label) { - return port(PortDefinition.output(key, label)); - } - - public NodeSchema build() { - return definitions.isEmpty() ? EMPTY : new NodeSchema(definitions); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodeSchemaFactory.java b/src/main/java/dev/propulsionteam/computed/api/node/NodeSchemaFactory.java deleted file mode 100644 index 8a06226..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodeSchemaFactory.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.Objects; - -/** Produces an instance schema from its typed properties, enabling stable dynamic ports. */ -@FunctionalInterface -public interface NodeSchemaFactory { - NodeSchema create(NodePropertyBag properties); - - static NodeSchemaFactory fixed(NodeSchema schema) { - Objects.requireNonNull(schema, "schema"); - return ignored -> schema; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/NodeType.java b/src/main/java/dev/propulsionteam/computed/api/node/NodeType.java deleted file mode 100644 index f581d07..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/NodeType.java +++ /dev/null @@ -1,181 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import com.mojang.serialization.Codec; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.function.Supplier; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -/** Immutable public definition of a Computed node type. */ -public final class NodeType { - private final ResourceLocation id; - private final Component title; - private final ResourceLocation category; - private final NodeSchemaFactory schemaFactory; - private final List> properties; - private final NodePropertyBag defaultProperties; - private final Codec stateCodec; - private final Supplier defaultStateFactory; - private final NodeExecutor evaluator; - private final boolean stateBoundary; - private final ExecutionPolicy executionPolicy; - - private NodeType(Builder builder) { - id = builder.id; - title = Objects.requireNonNull(builder.title, "Node type '" + id + "' has no title"); - category = Objects.requireNonNull(builder.category, "Node type '" + id + "' has no category"); - schemaFactory = Objects.requireNonNull(builder.schemaFactory, "Node type '" + id + "' has no schema"); - properties = List.copyOf(builder.properties); - defaultProperties = NodePropertyBag.defaults(properties); - Objects.requireNonNull(schemaFactory.create(defaultProperties), "Schema factory returned null for '" + id + "'"); - stateCodec = Objects.requireNonNull(builder.stateCodec, "Node type '" + id + "' has no state codec"); - defaultStateFactory = Objects.requireNonNull( - builder.defaultStateFactory, "Node type '" + id + "' has no default state"); - Objects.requireNonNull(defaultState(), "Default state for node type '" + id + "'"); - evaluator = Objects.requireNonNull(builder.evaluator, "Node type '" + id + "' has no evaluator"); - stateBoundary = builder.stateBoundary; - executionPolicy = builder.executionPolicy; - } - - public static Builder builder(ResourceLocation id) { - return new Builder<>(id); - } - - public ResourceLocation id() { - return id; - } - - public Component title() { - return title; - } - - public ResourceLocation category() { - return category; - } - - public List> properties() { - return properties; - } - - public NodePropertyBag defaultProperties() { - return defaultProperties; - } - - public NodeSchema schema(NodePropertyBag properties) { - Objects.requireNonNull(properties, "properties"); - return Objects.requireNonNull(schemaFactory.create(properties), "Schema factory returned null for '" + id + "'"); - } - - public NodeSchemaFactory schemaFactory() { - return schemaFactory; - } - - public Codec stateCodec() { - return stateCodec; - } - - public S defaultState() { - return defaultStateFactory.get(); - } - - public NodeExecutor evaluator() { - return evaluator; - } - - public boolean stateBoundary() { - return stateBoundary; - } - - public ExecutionPolicy executionPolicy() { - return executionPolicy; - } - - public static final class Builder { - private final ResourceLocation id; - private Component title; - private ResourceLocation category = ComputedNodeApi.UNCATEGORIZED_CATEGORY; - private NodeSchemaFactory schemaFactory; - private final List> properties = new ArrayList<>(); - private Codec stateCodec; - private Supplier defaultStateFactory; - private NodeExecutor evaluator; - private boolean stateBoundary; - private ExecutionPolicy executionPolicy = ExecutionPolicy.INPUT_DRIVEN; - - private Builder(ResourceLocation id) { - this.id = Objects.requireNonNull(id, "id"); - } - - public Builder title(Component title) { - this.title = Objects.requireNonNull(title, "title"); - return this; - } - - public Builder category(ResourceLocation category) { - this.category = Objects.requireNonNull(category, "category"); - return this; - } - - public Builder schema(NodeSchema schema) { - schemaFactory = NodeSchemaFactory.fixed(schema); - return this; - } - - public Builder schema(NodeSchemaFactory schemaFactory) { - this.schemaFactory = Objects.requireNonNull(schemaFactory, "schemaFactory"); - return this; - } - - public Builder property(NodeProperty property) { - Objects.requireNonNull(property, "property"); - if (properties.stream().anyMatch(existing -> existing.key().equals(property.key()))) { - throw new IllegalArgumentException( - "Duplicate property key '" + property.key() + "' on node type '" + id + "'"); - } - properties.add(property); - return this; - } - - public Builder properties(Iterable> properties) { - Objects.requireNonNull(properties, "properties").forEach(this::property); - return this; - } - - public Builder stateCodec(Codec stateCodec) { - this.stateCodec = Objects.requireNonNull(stateCodec, "stateCodec"); - return this; - } - - public Builder defaultState(S state) { - Objects.requireNonNull(state, "state"); - defaultStateFactory = () -> state; - return this; - } - - public Builder defaultState(Supplier stateFactory) { - defaultStateFactory = Objects.requireNonNull(stateFactory, "stateFactory"); - return this; - } - - public Builder evaluator(NodeExecutor evaluator) { - this.evaluator = Objects.requireNonNull(evaluator, "evaluator"); - return this; - } - - public Builder stateBoundary(boolean stateBoundary) { - this.stateBoundary = stateBoundary; - return this; - } - - public Builder executionPolicy(ExecutionPolicy executionPolicy) { - this.executionPolicy = Objects.requireNonNull(executionPolicy, "executionPolicy"); - return this; - } - - public NodeType build() { - return new NodeType<>(this); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/PortDefinition.java b/src/main/java/dev/propulsionteam/computed/api/node/PortDefinition.java deleted file mode 100644 index 6971a2e..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/PortDefinition.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.Objects; -import net.minecraft.network.chat.Component; - -/** Display and direction metadata for a stable port key. */ -public record PortDefinition(PortKey key, PortDirection direction, Component label) { - public PortDefinition { - Objects.requireNonNull(key, "key"); - Objects.requireNonNull(direction, "direction"); - Objects.requireNonNull(label, "label"); - } - - public static PortDefinition input(PortKey key, Component label) { - return new PortDefinition<>(key, PortDirection.INPUT, label); - } - - public static PortDefinition output(PortKey key, Component label) { - return new PortDefinition<>(key, PortDirection.OUTPUT, label); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/PortDirection.java b/src/main/java/dev/propulsionteam/computed/api/node/PortDirection.java deleted file mode 100644 index 693df87..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/PortDirection.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -/** Direction of data flow through a node port. */ -public enum PortDirection { - INPUT, - OUTPUT; - - public boolean canConnectTo(PortDirection other) { - return other != null && this != other; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/PortKey.java b/src/main/java/dev/propulsionteam/computed/api/node/PortKey.java deleted file mode 100644 index a896026..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/PortKey.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.Objects; -import java.util.regex.Pattern; - -/** A stable, typed identifier for one port in a node type's schema. */ -public final class PortKey { - private static final Pattern VALID_ID = Pattern.compile("[a-z][a-z0-9_.-]*"); - - private final String id; - private final PortType type; - - private PortKey(String id, PortType type) { - if (id == null || !VALID_ID.matcher(id).matches()) { - throw new IllegalArgumentException( - "Port key must match " + VALID_ID.pattern() + ", got: " + id); - } - this.id = id; - this.type = Objects.requireNonNull(type, "type"); - } - - public static PortKey of(String id, PortType type) { - return new PortKey<>(id, type); - } - - public String id() { - return id; - } - - public PortType type() { - return type; - } - - @Override - public boolean equals(Object other) { - return this == other - || other instanceof PortKey key && id.equals(key.id) && type == key.type; - } - - @Override - public int hashCode() { - return 31 * id.hashCode() + System.identityHashCode(type); - } - - @Override - public String toString() { - return id + ':' + type.id(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/PortType.java b/src/main/java/dev/propulsionteam/computed/api/node/PortType.java deleted file mode 100644 index c0ff182..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/PortType.java +++ /dev/null @@ -1,69 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import java.util.Objects; -import java.util.function.Supplier; - -/** - * The value type carried by a node port. - * - *

Computed deliberately exposes a closed set of types so graph validation and persistence do not - * depend on Java implementation classes supplied by addons.

- */ -public final class PortType { - public static final PortType NUMBER = - new PortType<>("number", Double.class, () -> 0.0D, 0xFFFFFFFF); - public static final PortType STRING = - new PortType<>("string", String.class, () -> "", 0xFFFFC830); - /** - * Opaque widget payload. Widget implementations are deliberately not part of this low-level API; - * a {@code null} value means that no widget is present. - */ - public static final PortType WIDGET = - new PortType<>("widget", Object.class, () -> null, 0xFF40D0FF); - - private final String id; - private final Class valueClass; - private final Supplier defaultFactory; - private final int defaultColor; - - private PortType(String id, Class valueClass, Supplier defaultFactory, int defaultColor) { - this.id = Objects.requireNonNull(id, "id"); - this.valueClass = Objects.requireNonNull(valueClass, "valueClass"); - this.defaultFactory = Objects.requireNonNull(defaultFactory, "defaultFactory"); - this.defaultColor = defaultColor; - } - - public String id() { - return id; - } - - public Class valueClass() { - return valueClass; - } - - /** - * Returns the neutral value emitted by an unconnected or disabled port. Widget ports return - * {@code null}, which is the explicit no-widget value. - */ - public T defaultValue() { - return defaultFactory.get(); - } - - public int defaultColor() { - return defaultColor; - } - - public boolean accepts(Object value) { - return value == null ? "widget".equals(id) : valueClass.isInstance(value); - } - - /** Returns {@code value} when it has this type, otherwise this type's neutral value. */ - public T castOrDefault(Object value) { - return accepts(value) ? valueClass.cast(value) : defaultValue(); - } - - @Override - public String toString() { - return id; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/client/ComputedNodeClientApi.java b/src/main/java/dev/propulsionteam/computed/api/node/client/ComputedNodeClientApi.java deleted file mode 100644 index f4d5f5f..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/client/ComputedNodeClientApi.java +++ /dev/null @@ -1,57 +0,0 @@ -package dev.propulsionteam.computed.api.node.client; - -import dev.propulsionteam.computed.api.node.NodeType; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.minecraft.resources.ResourceLocation; - -/** Client-only startup registry for optional custom node presentations. */ -public final class ComputedNodeClientApi { - private static final LinkedHashMap PRESENTATIONS = new LinkedHashMap<>(); - private static volatile boolean frozen; - - private ComputedNodeClientApi() {} - - public static synchronized NodePresentation registerPresentation( - ResourceLocation nodeType, NodePresentation presentation) { - ensureMutable(); - Objects.requireNonNull(nodeType, "nodeType"); - Objects.requireNonNull(presentation, "presentation"); - NodePresentation previous = PRESENTATIONS.putIfAbsent(nodeType, presentation); - if (previous != null) { - throw new IllegalStateException( - "Cannot register presentation for node type '" + nodeType + "': one is already registered"); - } - return presentation; - } - - public static NodePresentation registerPresentation(NodeType nodeType, NodePresentation presentation) { - return registerPresentation(Objects.requireNonNull(nodeType, "nodeType").id(), presentation); - } - - public static synchronized Optional presentation(ResourceLocation nodeType) { - return Optional.ofNullable(PRESENTATIONS.get(Objects.requireNonNull(nodeType, "nodeType"))); - } - - public static synchronized Map presentations() { - return Collections.unmodifiableMap(new LinkedHashMap<>(PRESENTATIONS)); - } - - public static synchronized void freeze() { - frozen = true; - } - - public static boolean isFrozen() { - return frozen; - } - - private static void ensureMutable() { - if (frozen) { - throw new IllegalStateException( - "Computed client node presentation registry is frozen; registrations must occur during client startup"); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/client/NodePresentation.java b/src/main/java/dev/propulsionteam/computed/api/node/client/NodePresentation.java deleted file mode 100644 index 7f10199..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/client/NodePresentation.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.propulsionteam.computed.api.node.client; - -/** Optional custom body renderer for a node; unregistered types use generic property controls. */ -@FunctionalInterface -public interface NodePresentation { - void render(NodePresentationContext context); -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/client/NodePresentationContext.java b/src/main/java/dev/propulsionteam/computed/api/node/client/NodePresentationContext.java deleted file mode 100644 index f793373..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/client/NodePresentationContext.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.propulsionteam.computed.api.node.client; - -import dev.propulsionteam.computed.api.node.NodeProperty; -import dev.propulsionteam.computed.api.node.NodePropertyBag; -import dev.propulsionteam.computed.api.node.NodeType; -import java.util.UUID; -import net.minecraft.client.gui.GuiGraphics; - -/** Client editor services supplied to a custom node presentation for one render pass. */ -public interface NodePresentationContext { - UUID nodeId(); - - NodeType nodeType(); - - NodePropertyBag properties(); - - GuiGraphics graphics(); - - int x(); - - int y(); - - int width(); - - int height(); - - int mouseX(); - - int mouseY(); - - float partialTick(); - - void setProperty(NodeProperty property, T value); - - /** Draws the standard controls generated from the node type's property definitions. */ - void renderGenericPropertyControls(); -} diff --git a/src/main/java/dev/propulsionteam/computed/api/node/client/package-info.java b/src/main/java/dev/propulsionteam/computed/api/node/client/package-info.java deleted file mode 100644 index 5f06c4c..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/client/package-info.java +++ /dev/null @@ -1,2 +0,0 @@ -/** Client-only extension points for custom Computed node presentations. */ -package dev.propulsionteam.computed.api.node.client; diff --git a/src/main/java/dev/propulsionteam/computed/api/node/package-info.java b/src/main/java/dev/propulsionteam/computed/api/node/package-info.java deleted file mode 100644 index c6acd47..0000000 --- a/src/main/java/dev/propulsionteam/computed/api/node/package-info.java +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Public, server-safe API for declaring typed Computed nodes. This package intentionally has no - * dependency on Minecraft client classes; editor-only extension points live in the {@code client} - * subpackage. - */ -package dev.propulsionteam.computed.api.node; diff --git a/src/main/java/dev/propulsionteam/computed/client/ClientFunctionLibraryFiles.java b/src/main/java/dev/propulsionteam/computed/client/ClientFunctionLibraryFiles.java deleted file mode 100644 index d8783fb..0000000 --- a/src/main/java/dev/propulsionteam/computed/client/ClientFunctionLibraryFiles.java +++ /dev/null @@ -1,116 +0,0 @@ -package dev.propulsionteam.computed.client; - -import com.mojang.logging.LogUtils; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.internal.node.ProgramBridge; -import dev.propulsionteam.computed.internal.node.api.FunctionDefinitionStore; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.node.program.ProgramCodec; -import java.awt.Desktop; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.stream.Stream; -import net.minecraft.client.Minecraft; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtIo; -import org.slf4j.Logger; - -/** - * Client-only folder under {@code config/computed/functions} for exporting/importing inner function graphs as - * {@code .nbt} files (same structure as stored {@link dev.propulsionteam.computed.internal.node.api.FunctionDefinitionStore} bodies). - */ -public final class ClientFunctionLibraryFiles { - private static final Logger LOGGER = LogUtils.getLogger(); - - private ClientFunctionLibraryFiles() {} - - public static Path rootPath() { - return Minecraft.getInstance() - .gameDirectory - .toPath() - .resolve("config") - .resolve(Computed.MODID) - .resolve("functions"); - } - - public static Path ensureRoot() { - Path p = rootPath(); - try { - Files.createDirectories(p); - } catch (IOException e) { - LOGGER.warn("Could not create {}", p, e); - } - return p; - } - - public static String safeBaseName(String displayName) { - String s = displayName == null ? "" : displayName.trim(); - if (s.isEmpty()) { - return "function"; - } - return s.replaceAll("[^a-zA-Z0-9._\\-]+", "_"); - } - - public static void saveInnerGraphTag(String displayName, CompoundTag innerGraphTag) { - Path dir = ensureRoot(); - Path file = dir.resolve(safeBaseName(displayName) + ".nbt"); - try { - WGraph graph = new WGraph(); - graph.load(innerGraphTag); - CompoundTag program = ProgramCodec.write( - ProgramBridge.snapshot(graph, new FunctionDefinitionStore(), 0L)); - NbtIo.writeCompressed(program, file); - } catch (IOException e) { - LOGGER.warn("Failed to save {}", file, e); - } - } - - public static List listNbtFiles(Path root) { - List out = new ArrayList<>(); - if (!Files.isDirectory(root)) { - return out; - } - try (Stream stream = Files.list(root)) { - stream.filter(p -> p.getFileName().toString().endsWith(".nbt")) - .sorted(Comparator.comparing(p -> p.getFileName().toString().toLowerCase())) - .forEach(out::add); - } catch (IOException e) { - LOGGER.warn("Failed to list {}", root, e); - } - return out; - } - - /** Opens the folder in the OS shell (Explorer / Finder / xdg-open). */ - public static void openFolder(Path dir) { - Path abs = dir.toAbsolutePath(); - try { - Files.createDirectories(abs); - } catch (IOException e) { - LOGGER.warn("Could not mkdir {}", abs, e); - } - try { - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { - Desktop.getDesktop().open(abs.toFile()); - return; - } - } catch (IOException | UnsupportedOperationException e) { - LOGGER.warn("Desktop.open failed for {}", abs, e); - } - String os = System.getProperty("os.name", "").toLowerCase(); - try { - if (os.contains("win")) { - new ProcessBuilder("cmd", "/c", "start", "", abs.toString()).start(); - } else if (os.contains("mac")) { - new ProcessBuilder("open", abs.toString()).start(); - } else { - new ProcessBuilder("xdg-open", abs.toString()).start(); - } - } catch (IOException e) { - LOGGER.warn("Could not open folder {}", abs, e); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/client/ComputedClientCommands.java b/src/main/java/dev/propulsionteam/computed/client/ComputedClientCommands.java deleted file mode 100644 index 6d82046..0000000 --- a/src/main/java/dev/propulsionteam/computed/client/ComputedClientCommands.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.propulsionteam.computed.client; - -import com.mojang.brigadier.CommandDispatcher; -import dev.propulsionteam.computed.customnodes.ComputedCustomNodes; -import net.minecraft.commands.CommandSourceStack; -import net.minecraft.commands.Commands; -import net.minecraft.network.chat.Component; - -public final class ComputedClientCommands { - private ComputedClientCommands() {} - - public static void register(CommandDispatcher dispatcher) { - dispatcher.register(Commands.literal("computed") - .then(Commands.literal("reload") - .executes(context -> { - var summary = ComputedCustomNodes.reload(); - context.getSource() - .sendSuccess( - () -> Component.literal("Custom nodes reloaded: loaded=" - + summary.loaded() + ", skipped=" + summary.skipped() - + ", warnings=" + summary.warnings() + ", errors=" - + summary.errors()), - false); - return summary.errors() == 0 ? 1 : 0; - }))); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/client/ComputedGraphShareCodec.java b/src/main/java/dev/propulsionteam/computed/client/ComputedGraphShareCodec.java deleted file mode 100644 index dd34e9f..0000000 --- a/src/main/java/dev/propulsionteam/computed/client/ComputedGraphShareCodec.java +++ /dev/null @@ -1,198 +0,0 @@ -package dev.propulsionteam.computed.client; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import dev.propulsionteam.computed.internal.node.api.FunctionCardNode; -import dev.propulsionteam.computed.internal.node.api.FunctionDefinitionStore; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.ProgramBridge; -import dev.propulsionteam.computed.node.program.ProgramCodec; -import dev.propulsionteam.computed.customnodes.ComputedCustomNodes; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.NbtAccounter; -import net.minecraft.nbt.NbtIo; -import net.minecraft.nbt.Tag; -import net.minecraft.nbt.TagParser; -import net.minecraft.resources.ResourceLocation; - -/** - * Portable graph share strings for Computed editor. - * - * Format: {@code CMP2:}. The CMP1 decoder remains read-only for migration. - */ -public final class ComputedGraphShareCodec { - public static final String PREFIX = "CMP2:"; - public static final String LEGACY_PREFIX = "CMP1:"; - private static final int FORMAT_VERSION = 2; - - private ComputedGraphShareCodec() {} - - public record Decoded(CompoundTag graph, ListTag functions, int embeddedCustomNodeCount, boolean legacy) {} - - public static String encode(WGraph graph, FunctionDefinitionStore functionStore) { - ListTag functions = functionStore != null ? functionStore.saveList() : new ListTag(); - CompoundTag root = ProgramCodec.write(ProgramBridge.snapshot(graph, functionStore, 0L)); - - List embedded = collectEmbeddedCustomNodeDefinitions(graph, functions); - if (!embedded.isEmpty()) { - ListTag defs = new ListTag(); - for (String raw : embedded) { - defs.add(net.minecraft.nbt.StringTag.valueOf(raw)); - } - root.put("embeddedCustomNodes", defs); - } - - try { - byte[] compressed = writeCompressed(root); - String b64 = Base64.getUrlEncoder().withoutPadding().encodeToString(compressed); - return PREFIX + b64; - } catch (IOException e) { - throw new IllegalStateException("Failed to encode graph", e); - } - } - - public static Decoded decode(String input) { - String trimmed = input == null ? "" : input.trim(); - if (trimmed.isEmpty()) { - throw new IllegalArgumentException("Empty import string"); - } - - boolean current = trimmed.startsWith(PREFIX); - boolean cmp1 = trimmed.startsWith(LEGACY_PREFIX); - if (current || cmp1) { - String prefix = current ? PREFIX : LEGACY_PREFIX; - String payload = trimmed.substring(prefix.length()); - try { - byte[] compressed = Base64.getUrlDecoder().decode(payload); - CompoundTag root = readCompressed(compressed); - int version = root.getInt("formatVersion"); - int expectedVersion = current ? FORMAT_VERSION : 1; - if (version != expectedVersion) { - throw new IllegalArgumentException("Unsupported share version: " + version); - } - List defs = new ArrayList<>(); - ListTag defsTag = root.getList("embeddedCustomNodes", Tag.TAG_STRING); - for (int i = 0; i < defsTag.size(); i++) { - defs.add(defsTag.getString(i)); - } - if (!defs.isEmpty()) { - ComputedCustomNodes.applyServerDefinitions(defs); - } - ProgramBridge.RuntimeProgram decoded = ProgramBridge.decode(root); - return new Decoded( - decoded.graph().save(), - decoded.functions().saveList(), - defs.size(), - cmp1 || decoded.migrated()); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid compressed share string", e); - } - } - - // Legacy fallback: Base64-encoded SNBT text. - try { - String decoded = new String(Base64.getDecoder().decode(trimmed), StandardCharsets.UTF_8); - CompoundTag root = TagParser.parseTag(decoded); - if (root.contains("nodes", Tag.TAG_LIST)) { - return new Decoded(root.copy(), new ListTag(), 0, true); - } - if (root.contains("graph", Tag.TAG_COMPOUND)) { - return new Decoded(root.getCompound("graph").copy(), root.getList("functions", Tag.TAG_COMPOUND).copy(), 0, true); - } - if (root.contains("ComputerGraph", Tag.TAG_COMPOUND)) { - return new Decoded( - root.getCompound("ComputerGraph").copy(), - root.getList("ComputerFunctions", Tag.TAG_COMPOUND).copy(), - 0, - true); - } - throw new IllegalArgumentException("Legacy payload missing graph data"); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid import string", e); - } - } - - private static List collectEmbeddedCustomNodeDefinitions(WGraph graph, ListTag functionList) { - Set usedTypes = collectUsedNodeTypes(graph, functionList); - if (usedTypes.isEmpty()) { - return List.of(); - } - Map allCustomById = new HashMap<>(); - for (String raw : ComputedCustomNodes.readRawDefinitions()) { - try { - JsonObject obj = JsonParser.parseString(raw).getAsJsonObject(); - if (!obj.has("id")) { - continue; - } - ResourceLocation id = ResourceLocation.parse(obj.get("id").getAsString()); - allCustomById.put(id, raw); - } catch (Exception ignored) { - } - } - List out = new ArrayList<>(); - for (ResourceLocation type : usedTypes) { - String raw = allCustomById.get(type); - if (raw != null) { - out.add(raw); - } - } - return out; - } - - private static Set collectUsedNodeTypes(WGraph graph, ListTag functionList) { - Set types = new HashSet<>(); - for (WNode node : graph.getNodes()) { - types.add(node.getTypeId()); - if (node instanceof FunctionCardNode card) { - collectNodeTypesFromGraphTag(card.getInnerGraph().save(), types); - } - } - for (int i = 0; i < functionList.size(); i++) { - CompoundTag def = functionList.getCompound(i); - collectNodeTypesFromGraphTag(def.getCompound("Body"), types); - } - return types; - } - - private static void collectNodeTypesFromGraphTag(CompoundTag graphTag, Set out) { - ListTag nodes = graphTag.getList("nodes", Tag.TAG_COMPOUND); - for (int i = 0; i < nodes.size(); i++) { - CompoundTag node = nodes.getCompound(i); - if (node.contains("typeId", Tag.TAG_STRING)) { - try { - out.add(ResourceLocation.parse(node.getString("typeId"))); - } catch (Exception ignored) { - } - } - } - } - - private static byte[] writeCompressed(CompoundTag tag) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (DataOutputStream dos = new DataOutputStream(baos)) { - NbtIo.writeCompressed(tag, dos); - } - return baos.toByteArray(); - } - - private static CompoundTag readCompressed(byte[] bytes) throws IOException { - try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(bytes))) { - return NbtIo.readCompressed(dis, NbtAccounter.unlimitedHeap()); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/client/ComputerEditorScreen.java b/src/main/java/dev/propulsionteam/computed/client/ComputerEditorScreen.java index 5280349..4a8c21e 100644 --- a/src/main/java/dev/propulsionteam/computed/client/ComputerEditorScreen.java +++ b/src/main/java/dev/propulsionteam/computed/client/ComputerEditorScreen.java @@ -1,193 +1,331 @@ package dev.propulsionteam.computed.client; -import dev.propulsionteam.computed.internal.node.api.FunctionDefinitionStore; +import dev.propulsionteam.computed.client.editor.canvas.LuaEditorGraphAdapter; +import dev.propulsionteam.computed.client.editor.canvas.LuaEditorNode; +import dev.propulsionteam.computed.client.editor.explorer.ExplorerNode; +import dev.propulsionteam.computed.client.editor.explorer.ExplorerRow; +import dev.propulsionteam.computed.client.editor.explorer.NodeExplorerModel; +import dev.propulsionteam.computed.client.editor.lua.LuaNodeStarter; +import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.LuaDefinitionSource; import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.ProgramBridge; +import dev.propulsionteam.computed.internal.node.api.WNode; import dev.propulsionteam.computed.internal.node.client.ui.WNodeScreen; -import dev.propulsionteam.computed.node.program.ComputedProgram; -import net.minecraft.client.Minecraft; -import net.minecraft.core.BlockPos; -import net.minecraft.nbt.CompoundTag; -import net.neoforged.neoforge.network.PacketDistributor; - -import dev.propulsionteam.computed.content.Peripherals; +import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.node.LuaDefinitionLoader; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; import dev.propulsionteam.computed.network.SaveComputerGraphPayload; -import java.nio.file.Path; -import java.util.List; +import dev.propulsionteam.computed.persistence.ProgramV3Codec; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; -import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.network.PacketDistributor; +import org.lwjgl.glfw.GLFW; -/** - * Per-block node UI; graph edits are sent back to the {@link dev.propulsionteam.computed.content.blocks.ComputerBlockEntity} when the screen closes. - */ public class ComputerEditorScreen extends WNodeScreen { private static final int AUTO_SAVE_INTERVAL_TICKS = 20; + private static final int EXPLORER_WIDTH = 224; + private static final int EXPLORER_ROW_HEIGHT = 15; + private static final String NEW_LUA_NODE_ACTION = "computed:editor/new_lua_node"; private final BlockPos computerPos; - private final WGraph editorGraph; - private final Set peripheralUnlock; - private final List placedPeripheralHud; + private WGraph editorGraph; + private final Map pendingPrograms = new HashMap<>(); + private final Map pendingHistoryRevisions = new HashMap<>(); + private ComputedProgramV3 baseProgram; private int autoSaveCountdown; private long serverRevision; private long acknowledgedEditorRevision; private long acknowledgedHistoryRevision; private long inFlightEditorRevision = -1; - private long inFlightHistoryRevision = -1; private boolean saveInFlight; private boolean saveBlocked; private long blockedEditorRevision = -1; private long blockedHistoryRevision = -1; - private ComputedProgram baseProgram; - private final Map pendingPrograms = new HashMap<>(); - private final Map pendingHistoryRevisions = new HashMap<>(); + private NodeExplorerModel explorer; + private boolean explorerOpen = true; + private boolean explorerSearchFocused; + private String explorerSearch = ""; + private int explorerAnchorX; + private int explorerAnchorY; + private int explorerScroll; + private boolean contextOpen; + private int contextX; + private int contextY; + private ExplorerNode explorerPressNode; + private double explorerPressX; + private double explorerPressY; + private boolean explorerDragging; public ComputerEditorScreen( BlockPos computerPos, - WGraph graph, - FunctionDefinitionStore functionStore, - Set peripheralUnlock, - List placedPeripheralHud, - long serverRevision, - ComputedProgram baseProgram) { - super(graph, functionStore, Peripherals.hardwareMissingPredicate(peripheralUnlock)); + ComputedProgramV3 program, + long serverRevision) { + this(computerPos, program, LuaEditorGraphAdapter.toEditorGraph(program), serverRevision); + } + + private ComputerEditorScreen( + BlockPos computerPos, + ComputedProgramV3 program, + WGraph editorGraph, + long serverRevision) { + super(editorGraph); this.computerPos = computerPos; - this.editorGraph = graph; - this.peripheralUnlock = Set.copyOf(peripheralUnlock); - this.placedPeripheralHud = List.copyOf(placedPeripheralHud); + this.editorGraph = editorGraph; this.serverRevision = serverRevision; - this.baseProgram = baseProgram == null ? null : baseProgram.withRevision(serverRevision); - Minecraft mc = Minecraft.getInstance(); - if (mc.player != null && mc.level != null) { - ComputerEditorViewState.load(mc.player.getUUID(), mc.level.dimension(), computerPos, EDITOR_VIEWPORT_ROOT) - .ifPresent(v -> restoreEditorViewport(v.panX(), v.panY(), v.zoom())); + baseProgram = program.withRevision(serverRevision); + explorer = new NodeExplorerModel(explorerNodes(program)); + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.player != null && minecraft.level != null) { + ComputerEditorViewState.load( + minecraft.player.getUUID(), + minecraft.level.dimension(), + computerPos, + EDITOR_VIEWPORT_ROOT) + .ifPresent(view -> restoreEditorViewport(view.panX(), view.panY(), view.zoom())); } } @Override - protected boolean isFunctionLibraryDefinitionHardwareLocked(FunctionDefinitionStore.Definition def) { - return Peripherals.graphNbtUsesMissingPeripheral(def.body(), peripheralUnlock); + protected boolean minimalCanvasMode() { + return true; } @Override - protected List placedPeripheralHudLines() { - return placedPeripheralHud; + protected void openNodeExplorer(int screenX, int screenY, int graphX, int graphY) { + explorerAnchorX = graphX; + explorerAnchorY = graphY; + if (selectNodeAtGraphPoint(graphX, graphY) || hasSelectedNodes()) { + contextOpen = true; + contextX = screenX; + contextY = screenY; + return; + } + contextOpen = true; + contextX = screenX; + contextY = screenY; + } + + @Override + protected WNode createDuplicateNode(WNode source, int x, int y) { + return source instanceof LuaEditorNode luaNode + ? LuaEditorGraphAdapter.duplicateEditorNode(luaNode, x, y) + : super.createDuplicateNode(source, x, y); } @Override protected void persistEditorViewport(String contextKey) { - Minecraft mc = Minecraft.getInstance(); - if (mc.player != null && mc.level != null) { - ComputerEditorViewState.save( - mc.player.getUUID(), - mc.level.dimension(), - computerPos, - contextKey, - editorPanX(), - editorPanY(), - editorZoom()); + saveEditorViewport(); + } + + @Override + protected boolean loadEditorViewport(String contextKey) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.player == null || minecraft.level == null) { + return false; } + return ComputerEditorViewState.load( + minecraft.player.getUUID(), + minecraft.level.dimension(), + computerPos, + contextKey) + .map(view -> { + restoreEditorViewport(view.panX(), view.panY(), view.zoom()); + return true; + }) + .orElse(false); } @Override - protected Path clientNestedFunctionsDirectory() { - return ClientFunctionLibraryFiles.ensureRoot(); + public void tick() { + super.tick(); + if (--autoSaveCountdown <= 0) { + autoSaveCountdown = AUTO_SAVE_INTERVAL_TICKS; + sendDirtyProgram(false); + } } @Override - protected void clientRevealNestedFunctionsFolder(Path directory) { - ClientFunctionLibraryFiles.openFolder(directory); + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.render(graphics, mouseX, mouseY, partialTick); + graphics.pose().pushPose(); + graphics.pose().translate(0, 0, 5000); + renderControls(graphics, mouseX, mouseY); + if (explorerOpen) { + renderExplorer(graphics, mouseX, mouseY); + } + if (contextOpen) { + renderContext(graphics, mouseX, mouseY); + } + if (explorerDragging && explorerPressNode != null) { + graphics.fill(mouseX + 5, mouseY + 5, mouseX + 17, mouseY + 17, ComputedEditorTheme.ACCENT); + graphics.drawString( + font, + explorerPressNode.title(), + mouseX + 21, + mouseY + 7, + ComputedEditorTheme.TEXT_HEADER, + true); + } + graphics.pose().popPose(); } @Override - protected boolean loadEditorViewport(String contextKey) { - Minecraft mc = Minecraft.getInstance(); - if (mc.player == null || mc.level == null) { - return false; + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (isEditorModalOpen()) { + return super.mouseClicked(mouseX, mouseY, button); } - return ComputerEditorViewState.load(mc.player.getUUID(), mc.level.dimension(), computerPos, contextKey) - .map( - v -> { - restoreEditorViewport(v.panX(), v.panY(), v.zoom()); - return true; - }) - .orElse(false); + if (button == 0 && handleControlsClick(mouseX, mouseY)) { + return true; + } + if (contextOpen) { + return handleContextClick(mouseX, mouseY, button); + } + if (explorerOpen && mouseX < EXPLORER_WIDTH) { + return handleExplorerClick(mouseX, mouseY, button); + } + return super.mouseClicked(mouseX, mouseY, button); } - private ComputedProgram programForNetwork(long revision) { - ComputedProgram snapshot = ProgramBridge.snapshot(editorGraph, functionStore, revision); - return ProgramBridge.reconcile(baseProgram, snapshot).withRevision(revision); + @Override + public boolean mouseDragged( + double mouseX, + double mouseY, + int button, + double dragX, + double dragY) { + if (button == 0 && explorerPressNode != null) { + if (Math.hypot(mouseX - explorerPressX, mouseY - explorerPressY) >= 4) { + explorerDragging = true; + } + return true; + } + return super.mouseDragged(mouseX, mouseY, button, dragX, dragY); } - private void saveEditorViewportIfPossible() { - Minecraft mc = Minecraft.getInstance(); - if (mc.player != null && mc.level != null) { - ComputerEditorViewState.save( - mc.player.getUUID(), - mc.level.dimension(), - computerPos, - editorViewportContextKey(), - editorPanX(), - editorPanY(), - editorZoom()); + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (button == 0 && explorerPressNode != null) { + ExplorerNode released = explorerPressNode; + boolean dragged = explorerDragging; + explorerPressNode = null; + explorerDragging = false; + if (dragged && mouseX >= EXPLORER_WIDTH) { + explorerAnchorX = editorGraphX(mouseX); + explorerAnchorY = editorGraphY(mouseY); + place(released); + } else if (!dragged) { + explorerAnchorX = editorGraphX(width / 2.0); + explorerAnchorY = editorGraphY(height / 2.0); + place(released); + } + return true; } + return super.mouseReleased(mouseX, mouseY, button); } @Override - public void tick() { - super.tick(); - if (--autoSaveCountdown <= 0) { - autoSaveCountdown = AUTO_SAVE_INTERVAL_TICKS; - sendDirtyProgram(false); + public boolean mouseScrolled( + double mouseX, + double mouseY, + double scrollX, + double scrollY) { + if (isEditorModalOpen()) { + return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); } + if (explorerOpen && mouseX < EXPLORER_WIDTH) { + int visible = Math.max(1, (height - 45) / EXPLORER_ROW_HEIGHT); + explorerScroll = net.minecraft.util.Mth.clamp( + explorerScroll - (int) Math.signum(scrollY), + 0, + Math.max(0, explorer.visibleRows().size() - visible)); + return true; + } + return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); } - private void sendDirtyProgram(boolean closing) { - long localRevision = editorRevision(); - long localHistoryRevision = editorHistoryRevision(); - if (saveBlocked) { - if (localRevision == blockedEditorRevision && localHistoryRevision == blockedHistoryRevision) { - return; + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (isEditorModalOpen()) { + return super.keyPressed(keyCode, scanCode, modifiers); + } + if (contextOpen && keyCode == GLFW.GLFW_KEY_ESCAPE) { + contextOpen = false; + return true; + } + if (explorerOpen && explorerSearchFocused) { + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + explorerSearchFocused = false; + return true; + } + if (keyCode == GLFW.GLFW_KEY_BACKSPACE && !explorerSearch.isEmpty()) { + explorerSearch = explorerSearch.substring(0, explorerSearch.length() - 1); + explorer.search(explorerSearch); + explorerScroll = 0; + return true; + } + if (keyCode == GLFW.GLFW_KEY_UP || keyCode == GLFW.GLFW_KEY_DOWN) { + explorer.moveSelection(keyCode == GLFW.GLFW_KEY_UP ? -1 : 1); + return true; + } + if (keyCode == GLFW.GLFW_KEY_ENTER) { + ExplorerRow selected = explorer.selected(); + if (selected != null) { + if (selected.folder()) { + explorer.toggleSelected(); + } else { + place(selected.node()); + } + } + return true; } - saveBlocked = false; - clearEditorSaveFailureDiagnostic(); } - if ((localRevision == acknowledgedEditorRevision - && localHistoryRevision == acknowledgedHistoryRevision - && !editorHistoryDirty())) { - return; + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char codePoint, int modifiers) { + if (isEditorModalOpen()) { + return super.charTyped(codePoint, modifiers); } - if (saveInFlight && !closing) { - return; + if (explorerOpen + && explorerSearchFocused + && !Character.isISOControl(codePoint) + && explorerSearch.length() < 64) { + explorerSearch += codePoint; + explorer.search(explorerSearch); + explorerScroll = 0; + return true; } - long expectedRevision = serverRevision + (saveInFlight && closing ? 1 : 0); - ComputedProgram outgoing = programForNetwork(expectedRevision); - PacketDistributor.sendToServer(new SaveComputerGraphPayload( - computerPos, expectedRevision, localRevision, ProgramBridge.writeEnvelope(outgoing))); - saveInFlight = true; - pendingPrograms.put(localRevision, outgoing); - pendingHistoryRevisions.put(localRevision, localHistoryRevision); - inFlightEditorRevision = localRevision; - inFlightHistoryRevision = localHistoryRevision; - saveEditorViewportIfPossible(); + return super.charTyped(codePoint, modifiers); } - /** Applies the server acknowledgement without replacing or discarding the local editor graph. */ - public void onServerSaveResult(boolean accepted, long newServerRevision, long savedEditorRevision, String message) { - if (accepted || newServerRevision >= 0) serverRevision = newServerRevision; + public void onServerSaveResult( + boolean accepted, + long newServerRevision, + long savedEditorRevision, + String message) { + if (accepted || newServerRevision >= 0) { + serverRevision = newServerRevision; + } long savedHistoryRevision = pendingHistoryRevisions.getOrDefault(savedEditorRevision, -1L); - ComputedProgram acknowledgedProgram = pendingPrograms.remove(savedEditorRevision); + ComputedProgramV3 acknowledged = pendingPrograms.remove(savedEditorRevision); pendingHistoryRevisions.remove(savedEditorRevision); saveInFlight = !pendingPrograms.isEmpty(); if (savedEditorRevision == inFlightEditorRevision) { inFlightEditorRevision = -1; - inFlightHistoryRevision = -1; } if (accepted) { - if (acknowledgedProgram != null) { - baseProgram = acknowledgedProgram.withRevision(newServerRevision); + if (acknowledged != null) { + baseProgram = acknowledged.withRevision(newServerRevision); } acknowledgedEditorRevision = Math.max(acknowledgedEditorRevision, savedEditorRevision); if (savedHistoryRevision >= 0) { @@ -198,20 +336,14 @@ public void onServerSaveResult(boolean accepted, long newServerRevision, long sa saveBlocked = false; return; } - if (acknowledgedProgram == null) { - // A copy-on-load replacement deliberately detached this older in-flight snapshot. - // Retry the replacement against the server revision returned by this acknowledgement. - saveBlocked = false; - clearEditorSaveFailureDiagnostic(); - return; - } saveBlocked = true; blockedEditorRevision = editorRevision(); blockedHistoryRevision = editorHistoryRevision(); setEditorSaveFailureDiagnostic("Save rejected: " + message); if (minecraft != null && minecraft.player != null) { minecraft.player.displayClientMessage( - Component.literal("Computed graph was not saved: " + message), false); + Component.literal("Computed graph was not saved: " + message), + false); } } @@ -219,10 +351,437 @@ public boolean editsComputer(BlockPos pos) { return computerPos.equals(pos); } + String definitionHash(String id) { + LuaDefinitionSource source = LuaEditorGraphAdapter.definitions(baseProgram).get(id); + return source == null ? "" : source.hash(); + } + + void applyLuaSource( + String source, + String id, + boolean creationMode, + int placementX, + int placementY) { + LuaDefinitionSource replacement = LuaDefinitionSource.embedded(1, id, source); + ComputedProgramV3 current = LuaEditorGraphAdapter.fromEditorGraph( + editorGraph, + baseProgram, + serverRevision); + baseProgram = creationMode + ? LuaEditorGraphAdapter.addDefinitionAndNode( + current, + replacement, + placementX, + placementY) + : LuaEditorGraphAdapter.replaceDefinition(current, replacement); + editorGraph = LuaEditorGraphAdapter.toEditorGraph(baseProgram); + replaceCanvasGraph(editorGraph); + explorer = new NodeExplorerModel(explorerNodes(baseProgram)); + explorerSearch = ""; + explorer.search(""); + explorerScroll = 0; + acknowledgedEditorRevision = -1; + } + @Override public void removed() { - saveEditorViewportIfPossible(); + saveEditorViewport(); super.removed(); sendDirtyProgram(true); } + + private ComputedProgramV3 programForNetwork(long revision) { + return LuaEditorGraphAdapter.fromEditorGraph(editorGraph, baseProgram, revision); + } + + private void sendDirtyProgram(boolean closing) { + long localRevision = editorRevision(); + long historyRevision = editorHistoryRevision(); + if (saveBlocked) { + if (localRevision == blockedEditorRevision && historyRevision == blockedHistoryRevision) { + return; + } + saveBlocked = false; + clearEditorSaveFailureDiagnostic(); + } + if (localRevision == acknowledgedEditorRevision + && historyRevision == acknowledgedHistoryRevision + && !editorHistoryDirty()) { + return; + } + if (saveInFlight && !closing) { + return; + } + long expectedRevision = serverRevision + (saveInFlight && closing ? 1 : 0); + ComputedProgramV3 outgoing = programForNetwork(expectedRevision); + CompoundTag envelope = new CompoundTag(); + envelope.put(ComputerBlockEntity.PROGRAM_TAG, ProgramV3Codec.encode(outgoing)); + PacketDistributor.sendToServer(new SaveComputerGraphPayload( + computerPos, + expectedRevision, + localRevision, + envelope)); + saveInFlight = true; + pendingPrograms.put(localRevision, outgoing); + pendingHistoryRevisions.put(localRevision, historyRevision); + inFlightEditorRevision = localRevision; + saveEditorViewport(); + } + + private void saveEditorViewport() { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.player != null && minecraft.level != null) { + ComputerEditorViewState.save( + minecraft.player.getUUID(), + minecraft.level.dimension(), + computerPos, + EDITOR_VIEWPORT_ROOT, + editorPanX(), + editorPanY(), + editorZoom()); + } + } + + private void renderControls(GuiGraphics graphics, int mouseX, int mouseY) { + drawButton(graphics, 6, 6, 22, 18, explorerOpen ? "«" : "»", mouseX, mouseY); + drawButton(graphics, width - 54, 6, 22, 18, "−", mouseX, mouseY); + drawButton(graphics, width - 28, 6, 22, 18, "+", mouseX, mouseY); + } + + private void renderExplorer(GuiGraphics graphics, int mouseX, int mouseY) { + graphics.fill(0, 0, EXPLORER_WIDTH, height, 0xF2111111); + graphics.vLine(EXPLORER_WIDTH - 1, 0, height, ComputedEditorTheme.BORDER_MENU); + graphics.drawString(font, "Node Explorer", 34, 11, ComputedEditorTheme.TEXT_HEADER, false); + int searchColor = explorerSearchFocused + ? ComputedEditorTheme.BORDER_HIGHLIGHT + : ComputedEditorTheme.BORDER_DEFAULT; + graphics.fill(6, 27, EXPLORER_WIDTH - 6, 43, ComputedEditorTheme.BACKGROUND_INPUT); + graphics.renderOutline(6, 27, EXPLORER_WIDTH - 12, 16, searchColor); + String searchText = explorerSearch.isEmpty() && !explorerSearchFocused + ? "Search nodes" + : explorerSearch + (explorerSearchFocused ? "_" : ""); + graphics.drawString( + font, + searchText, + 11, + 31, + explorerSearch.isEmpty() + ? ComputedEditorTheme.TEXT_TERTIARY + : ComputedEditorTheme.TEXT_PRIMARY, + false); + List rows = explorer.visibleRows(); + int visible = Math.max(1, (height - 45) / EXPLORER_ROW_HEIGHT); + explorerScroll = net.minecraft.util.Mth.clamp( + explorerScroll, + 0, + Math.max(0, rows.size() - visible)); + String unavailableTooltip = ""; + for (int index = explorerScroll; index < Math.min(rows.size(), explorerScroll + visible); index++) { + ExplorerRow row = rows.get(index); + int y = 46 + (index - explorerScroll) * EXPLORER_ROW_HEIGHT; + boolean hovered = mouseX >= 0 + && mouseX < EXPLORER_WIDTH + && mouseY >= y + && mouseY < y + EXPLORER_ROW_HEIGHT; + if (hovered) { + graphics.fill(1, y, EXPLORER_WIDTH - 1, y + EXPLORER_ROW_HEIGHT, ComputedEditorTheme.MENU_HOVER); + } + int x = 8 + row.depth() * 12; + if (row.depth() > 0) { + graphics.vLine(x - 5, y, y + EXPLORER_ROW_HEIGHT, ComputedEditorTheme.BORDER_SUBTLE); + } + if (row.folder()) { + graphics.drawString( + font, + row.expanded() ? "▾" : "▸", + x, + y + 3, + ComputedEditorTheme.TEXT_SECONDARY, + false); + graphics.drawString( + font, + row.label(), + x + 10, + y + 3, + ComputedEditorTheme.TEXT_PRIMARY, + false); + } else { + int color = row.node().available() + ? ComputedEditorTheme.ACCENT + : ComputedEditorTheme.TEXT_DISABLED; + graphics.fill(x + 1, y + 4, x + 7, y + 10, color); + graphics.drawString( + font, + row.label(), + x + 11, + y + 3, + row.node().available() + ? ComputedEditorTheme.TEXT_PRIMARY + : ComputedEditorTheme.TEXT_DISABLED, + false); + if (hovered && !row.node().available()) { + unavailableTooltip = row.node().unavailableReason(); + } + } + } + if (!unavailableTooltip.isEmpty()) { + graphics.renderTooltip(font, Component.literal(unavailableTooltip), mouseX, mouseY); + } + } + + private void renderContext(GuiGraphics graphics, int mouseX, int mouseY) { + boolean selected = hasSelectedNodes(); + List rows = selected + ? List.of("Clone", "Unlink", "Delete") + : List.of("Open Node Explorer", "New Lua Node…", "Paste Lua"); + int menuWidth = selected ? 92 : 132; + int menuHeight = rows.size() * 18 + 4; + int x = Math.min(contextX, width - menuWidth - 2); + int y = Math.min(contextY, height - menuHeight - 2); + graphics.fill(x, y, x + menuWidth, y + menuHeight, ComputedEditorTheme.MENU_BACKGROUND); + graphics.renderOutline(x, y, menuWidth, menuHeight, ComputedEditorTheme.BORDER_MENU); + for (int index = 0; index < rows.size(); index++) { + int rowY = y + 2 + index * 18; + if (mouseX >= x && mouseX < x + menuWidth && mouseY >= rowY && mouseY < rowY + 18) { + graphics.fill(x + 1, rowY, x + menuWidth - 1, rowY + 18, ComputedEditorTheme.MENU_HOVER); + } + graphics.drawString( + font, + rows.get(index), + x + 7, + rowY + 5, + selected && index == 2 + ? ComputedEditorTheme.STATUS_ERROR_TEXT + : ComputedEditorTheme.TEXT_PRIMARY, + false); + } + } + + private boolean handleControlsClick(double mouseX, double mouseY) { + if (contains(mouseX, mouseY, 6, 6, 22, 18)) { + explorerOpen = !explorerOpen; + explorerSearchFocused = false; + return true; + } + if (contains(mouseX, mouseY, width - 54, 6, 22, 18)) { + adjustEditorZoom(-0.1, width / 2.0, height / 2.0); + return true; + } + if (contains(mouseX, mouseY, width - 28, 6, 22, 18)) { + adjustEditorZoom(0.1, width / 2.0, height / 2.0); + return true; + } + return false; + } + + private boolean handleExplorerClick(double mouseX, double mouseY, int button) { + if (contains(mouseX, mouseY, 6, 27, EXPLORER_WIDTH - 12, 16)) { + if (button == 0) { + explorerSearchFocused = true; + } + return true; + } + if (mouseY < 46) { + return true; + } + List rows = explorer.visibleRows(); + int index = explorerScroll + ((int) mouseY - 46) / EXPLORER_ROW_HEIGHT; + if (index < 0 || index >= rows.size()) { + return true; + } + ExplorerRow row = rows.get(index); + if (!row.folder() + && row.node().available() + && row.node().id().equals(NEW_LUA_NODE_ACTION) + && button == 0) { + explorerAnchorX = editorGraphX(width / 2.0); + explorerAnchorY = editorGraphY(height / 2.0); + openNewLuaNode(explorerAnchorX, explorerAnchorY); + return true; + } + if (row.folder() && button == 0) { + explorer.setExpanded(row.stablePath(), !row.expanded()); + } else if (!row.folder() && row.node().available() && button == 1) { + LuaDefinitionSource source = LuaEditorGraphAdapter.definitions(baseProgram).get(row.node().id()); + if (source != null) { + minecraft.setScreen(new LuaNodeEditorScreen(this, baseProgram, source.source())); + } + } else if (!row.folder() && row.node().available() && button == 0) { + explorerPressNode = row.node(); + explorerPressX = mouseX; + explorerPressY = mouseY; + explorerDragging = false; + } + return true; + } + + private boolean handleContextClick(double mouseX, double mouseY, int button) { + if (button != 0) { + contextOpen = false; + return true; + } + boolean selected = hasSelectedNodes(); + int menuWidth = selected ? 92 : 132; + int rowCount = 3; + int menuHeight = rowCount * 18 + 4; + int x = Math.min(contextX, width - menuWidth - 2); + int y = Math.min(contextY, height - menuHeight - 2); + if (!contains(mouseX, mouseY, x, y, menuWidth, menuHeight)) { + contextOpen = false; + return true; + } + int row = ((int) mouseY - y - 2) / 18; + contextOpen = false; + if (selected) { + if (row == 0) { + cloneSelectedNodes(); + } else if (row == 1) { + unlinkSelectedNodes(); + } else if (row == 2) { + removeSelectedNodes(); + } + } else if (row == 0) { + explorerOpen = true; + explorerSearchFocused = true; + } else if (row == 1) { + openNewLuaNode(explorerAnchorX, explorerAnchorY); + } else if (row == 2) { + openLuaFromClipboard(); + } + return true; + } + + private void place(ExplorerNode node) { + if (node == null || !node.available()) { + return; + } + if (node.id().equals(NEW_LUA_NODE_ACTION)) { + explorerAnchorX = editorGraphX(width / 2.0); + explorerAnchorY = editorGraphY(height / 2.0); + openNewLuaNode(explorerAnchorX, explorerAnchorY); + return; + } + LuaEditorNode placed = LuaEditorGraphAdapter.createEditorNode( + baseProgram, + node.id(), + explorerAnchorX, + explorerAnchorY); + addNodeToCanvas(placed); + } + + private void openLuaFromClipboard() { + String source; + try { + source = dev.propulsionteam.computed.persistence.LuaDefinitionClipboard.importSource( + minecraft.keyboardHandler.getClipboard()); + } catch (IllegalArgumentException exception) { + source = """ + local node = computed.node(1, "example:new_node", "New Node") + + node:category("lua") + node:input("value", "number", { default = 0 }) + node:output("result", "number") + node:on_run(function(ctx) + ctx:output("result", ctx:input("value")) + end) + + return node + """; + } + minecraft.setScreen(new LuaNodeEditorScreen(this, baseProgram, source)); + } + + private void openNewLuaNode(int x, int y) { + LuaNodeStarter.Starter starter = LuaNodeStarter.create(); + minecraft.setScreen(new LuaNodeEditorScreen( + this, + baseProgram, + starter.source(), + true, + x, + y)); + } + + private static void drawButton( + GuiGraphics graphics, + int x, + int y, + int width, + int height, + String text, + int mouseX, + int mouseY) { + boolean hovered = contains(mouseX, mouseY, x, y, width, height); + graphics.fill( + x, + y, + x + width, + y + height, + hovered ? ComputedEditorTheme.BUTTON_HOVER : ComputedEditorTheme.BUTTON_BACKGROUND); + graphics.renderOutline(x, y, width, height, ComputedEditorTheme.BORDER_MENU); + graphics.drawCenteredString( + Minecraft.getInstance().font, + text, + x + width / 2, + y + 5, + ComputedEditorTheme.TEXT_PRIMARY); + } + + private static boolean contains( + double mouseX, + double mouseY, + int x, + int y, + int width, + int height) { + return mouseX >= x && mouseX < x + width && mouseY >= y && mouseY < y + height; + } + + private static List explorerNodes(ComputedProgramV3 program) { + List nodes = new ArrayList<>(); + nodes.add(new ExplorerNode( + NEW_LUA_NODE_ACTION, + "New Lua Node…", + ExplorerNode.Ownership.USER, + List.of(), + true, + "")); + LuaSourceCompiler compiler = new LuaSourceCompiler(); + LuaDefinitionLoader loader = new LuaDefinitionLoader(); + LuaSandbox sandbox = new LuaSandbox(); + LuaEditorGraphAdapter.definitions(program).forEach((id, source) -> { + try { + var definition = loader.load(compiler.compile(source.apiVersion(), source.source()), sandbox); + ExplorerNode.Ownership ownership = switch (source.origin()) { + case BUNDLED -> ExplorerNode.Ownership.BUNDLED; + case INTEGRATION -> ExplorerNode.Ownership.INTEGRATION; + case EMBEDDED -> ExplorerNode.Ownership.USER; + }; + List path = java.util.Arrays.stream(definition.category().split("/")) + .filter(segment -> !segment.isBlank()) + .toList(); + String unavailableReason = source.origin() == LuaDefinitionSource.Origin.INTEGRATION + ? dev.propulsionteam.computed.lua.node.IntegrationLuaLibrary.unavailableReason(id) + : ""; + nodes.add(new ExplorerNode( + id, + definition.title(), + ownership, + path, + unavailableReason.isEmpty(), + unavailableReason)); + } catch (RuntimeException ignored) { + nodes.add(new ExplorerNode( + id, + id, + source.origin() == LuaDefinitionSource.Origin.INTEGRATION + ? ExplorerNode.Ownership.INTEGRATION + : ExplorerNode.Ownership.USER, + List.of("invalid"), + false, + "Definition could not be compiled")); + } + }); + return List.copyOf(nodes); + } } diff --git a/src/main/java/dev/propulsionteam/computed/client/LuaNodeEditorScreen.java b/src/main/java/dev/propulsionteam/computed/client/LuaNodeEditorScreen.java new file mode 100644 index 0000000..2ecab13 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/LuaNodeEditorScreen.java @@ -0,0 +1,1165 @@ +package dev.propulsionteam.computed.client; + +import dev.propulsionteam.computed.client.editor.canvas.LuaEditorGraphAdapter; +import dev.propulsionteam.computed.client.editor.canvas.LuaEditorNode; +import dev.propulsionteam.computed.client.editor.lua.LuaEditorSession; +import dev.propulsionteam.computed.client.editor.lua.LuaSyntaxHighlighter; +import dev.propulsionteam.computed.client.editor.preview.LuaLivePreview; +import dev.propulsionteam.computed.graph.ComputedGraph; +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpoints; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.node.LuaDefinitionFiles; +import dev.propulsionteam.computed.persistence.LuaDefinitionClipboard; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import org.lwjgl.glfw.GLFW; +import org.luaj.vm2.LuaValue; + +public final class LuaNodeEditorScreen extends Screen { + private static final int SOURCE_TOP = 26; + private static final int SOURCE_TEXT_X = 42; + private static final int SOURCE_TEXT_Y = 31; + private static final int SOURCE_LINE_HEIGHT = 11; + private static final int SCROLLBAR_SIZE = 7; + private static final int SCROLLBAR_MIN_THUMB = 12; + private static final int HORIZONTAL_SCROLLBAR_Y_OFFSET = 40; + private static final int SOURCE_BOTTOM_RESERVE = 42; + private static final List COMPLETIONS = List.of( + "computed.node(1, \"namespace:id\", \"Title\")", + "node:category(\"utility\")", + "node:style(\"standard\")", + "node:input(\"id\", \"number\", { default = 0 })", + "node:output(\"id\", \"number\")", + "node:field(\"id\", \"number\", { default = 0 })", + "node:field(\"id\", \"number\", { default = 0, min = 0, max = 1, control = \"slider\", step = 0.01, label = \"Value\" })", + "node:state(\"id\", 0)", + "node:execution(\"input\")", + "node:on_run(function(ctx)", + "node:on_event(\"event\", function(ctx, value)", + "ctx:input(\"id\")", + "ctx:output(\"id\", value)", + "ctx:field(\"id\")", + "ctx:state(\"id\")", + "ctx:set_state(\"id\", value)", + "ctx:endpoint(\"namespace:endpoint\")", + "ctx:emit(\"event\")", + "ctx:tick()", + "ctx:graph_step()", + "ctx:is_preview()"); + + private final ComputerEditorScreen parent; + private final ComputedProgramV3 program; + private final boolean creationMode; + private final int placementX; + private final int placementY; + private final LuaEditorSession session = new LuaEditorSession(); + private final Map sampleInputs = new LinkedHashMap<>(); + private final Map sampleFields = new LinkedHashMap<>(); + private String source; + private int cursor; + private int firstLine; + private int horizontalScroll; + private boolean revealCursor = true; + private boolean draggingVerticalScrollbar; + private boolean draggingHorizontalScrollbar; + private int scrollbarDragOffset; + private List> highlightedLines; + private boolean sourceFocused = true; + private boolean completionOpen; + private int completionIndex; + private boolean replacementConfirmation; + private String status = ""; + private LuaLivePreview preview; + private LuaEditorNode previewNode; + private LuaNodeDefinition previewDefinition; + private String fileDefinitionId = ""; + + public LuaNodeEditorScreen( + ComputerEditorScreen parent, + ComputedProgramV3 program, + String source) { + this(parent, program, source, false, 0, 0); + } + + public LuaNodeEditorScreen( + ComputerEditorScreen parent, + ComputedProgramV3 program, + String source, + boolean creationMode, + int placementX, + int placementY) { + super(Component.literal("Lua Node Editor")); + this.parent = parent; + this.program = program; + this.creationMode = creationMode; + this.placementX = placementX; + this.placementY = placementY; + this.source = source == null ? "" : source; + cursor = this.source.length(); + highlightedLines = LuaSyntaxHighlighter.highlight(this.source); + BuiltinEndpoints.register(); + session.sourceChanged(this.source, net.minecraft.Util.getMillis() - LuaEditorSession.DEBOUNCE_MILLIS); + updateCompilation(net.minecraft.Util.getMillis()); + } + + @Override + public void tick() { + updateCompilation(net.minecraft.Util.getMillis()); + } + + @Override + protected void init() { + centerPreviewNode(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, width, height, ComputedEditorTheme.BACKGROUND_PRIMARY); + int divider = Math.max(300, width * 3 / 5); + graphics.fill(divider, 0, divider + 1, height, ComputedEditorTheme.BORDER_MENU); + renderHeader(graphics, divider, mouseX, mouseY); + renderSource(graphics, divider); + renderPreview(graphics, divider, mouseX, mouseY, partialTick); + renderDiagnostics(graphics, divider); + if (completionOpen) { + renderCompletion(graphics, divider); + } + super.render(graphics, mouseX, mouseY, partialTick); + } + + @Override + public void renderBackground( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick) { + // This screen supplies its own opaque background. The vanilla pass would + // apply the in-world menu blur after the editor has already been drawn. + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button != 0) { + return true; + } + int divider = Math.max(300, width * 3 / 5); + if (beginScrollbarDrag(mouseX, mouseY, divider)) { + sourceFocused = true; + completionOpen = false; + revealCursor = false; + return true; + } + if (contains(mouseX, mouseY, divider - 292, 5, 42, 18)) { + minecraft.keyboardHandler.setClipboard(source); + status = "Lua source copied"; + return true; + } + if (contains(mouseX, mouseY, divider - 247, 5, 42, 18)) { + loadClipboard(); + return true; + } + if (contains(mouseX, mouseY, divider - 202, 5, 42, 18)) { + exportFile(); + return true; + } + if (contains(mouseX, mouseY, divider - 157, 5, 42, 18)) { + importFile(); + return true; + } + if (contains(mouseX, mouseY, divider - 112, 5, 50, 18)) { + resetPreview(); + return true; + } + if (contains(mouseX, mouseY, divider - 59, 5, 53, 18)) { + apply(); + return true; + } + if (contains(mouseX, mouseY, 0, 26, divider, Math.max(0, height - 41))) { + sourceFocused = true; + completionOpen = false; + int line = firstLine + Math.max(0, ((int) mouseY - 31) / 11); + String[] lines = source.split("\n", -1); + int clampedLine = Mth.clamp(line, 0, Math.max(0, lines.length - 1)); + cursor = offsetAtLine( + clampedLine, + columnAtPixel( + lines[clampedLine], + (int) mouseX - SOURCE_TEXT_X + horizontalScroll)); + revealCursor = false; + return true; + } + sourceFocused = false; + return handleSampleClick(mouseX, mouseY, divider); + } + + @Override + public boolean mouseScrolled( + double mouseX, + double mouseY, + double scrollX, + double scrollY) { + int divider = Math.max(300, width * 3 / 5); + if (mouseX < divider) { + if (hasShiftDown() || scrollX != 0) { + double amount = scrollX != 0 ? scrollX : scrollY; + horizontalScroll = Mth.clamp( + horizontalScroll - scrollPixels(amount), + 0, + maximumHorizontalScroll(divider)); + revealCursor = false; + return true; + } + firstLine = Mth.clamp( + firstLine - scrollLines(scrollY), + 0, + maximumVerticalScroll()); + revealCursor = false; + return true; + } + return false; + } + + @Override + public boolean mouseDragged( + double mouseX, + double mouseY, + int button, + double dragX, + double dragY) { + if (button == 0 && (draggingVerticalScrollbar || draggingHorizontalScrollbar)) { + int divider = Math.max(300, width * 3 / 5); + if (draggingVerticalScrollbar) { + setVerticalScrollFromThumb((int) mouseY - scrollbarDragOffset); + } else { + setHorizontalScrollFromThumb((int) mouseX - scrollbarDragOffset, divider); + } + revealCursor = false; + return true; + } + return super.mouseDragged(mouseX, mouseY, button, dragX, dragY); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (button == 0 && (draggingVerticalScrollbar || draggingHorizontalScrollbar)) { + draggingVerticalScrollbar = false; + draggingHorizontalScrollbar = false; + return true; + } + return super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + if (completionOpen) { + completionOpen = false; + } else { + onClose(); + } + return true; + } + if (!sourceFocused) { + return super.keyPressed(keyCode, scanCode, modifiers); + } + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_ENTER) { + apply(); + return true; + } + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_SPACE) { + completionOpen = true; + completionIndex = 0; + return true; + } + if (completionOpen && (keyCode == GLFW.GLFW_KEY_UP || keyCode == GLFW.GLFW_KEY_DOWN)) { + completionIndex = Math.floorMod( + completionIndex + (keyCode == GLFW.GLFW_KEY_UP ? -1 : 1), + COMPLETIONS.size()); + return true; + } + if (completionOpen && keyCode == GLFW.GLFW_KEY_ENTER) { + insert(COMPLETIONS.get(completionIndex)); + completionOpen = false; + return true; + } + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_A) { + cursor = source.length(); + revealCursor = true; + return true; + } + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_C) { + minecraft.keyboardHandler.setClipboard(source); + return true; + } + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_V) { + insert(minecraft.keyboardHandler.getClipboard()); + return true; + } + if (keyCode == GLFW.GLFW_KEY_LEFT) { + cursor = Math.max(0, cursor - 1); + revealCursor = true; + return true; + } + if (keyCode == GLFW.GLFW_KEY_RIGHT) { + cursor = Math.min(source.length(), cursor + 1); + revealCursor = true; + return true; + } + if (keyCode == GLFW.GLFW_KEY_UP || keyCode == GLFW.GLFW_KEY_DOWN) { + moveVertical(keyCode == GLFW.GLFW_KEY_UP ? -1 : 1); + revealCursor = true; + return true; + } + if (keyCode == GLFW.GLFW_KEY_HOME) { + cursor = lineStart(cursor); + revealCursor = true; + return true; + } + if (keyCode == GLFW.GLFW_KEY_END) { + cursor = lineEnd(cursor); + revealCursor = true; + return true; + } + if (keyCode == GLFW.GLFW_KEY_BACKSPACE && cursor > 0) { + source = source.substring(0, cursor - 1) + source.substring(cursor); + cursor--; + changed(); + revealCursor = true; + return true; + } + if (keyCode == GLFW.GLFW_KEY_DELETE && cursor < source.length()) { + source = source.substring(0, cursor) + source.substring(cursor + 1); + changed(); + revealCursor = true; + return true; + } + if (keyCode == GLFW.GLFW_KEY_ENTER) { + insert("\n" + indentation()); + return true; + } + if (keyCode == GLFW.GLFW_KEY_TAB) { + insert(" "); + return true; + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char codePoint, int modifiers) { + if (sourceFocused + && !Character.isISOControl(codePoint) + && source.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 65_536) { + insert(Character.toString(codePoint)); + return true; + } + return false; + } + + @Override + public void onClose() { + minecraft.setScreen(parent); + } + + private void renderHeader(GuiGraphics graphics, int divider, int mouseX, int mouseY) { + graphics.fill(0, 0, width, 26, ComputedEditorTheme.BACKGROUND_SECONDARY); + graphics.hLine(0, width, 25, ComputedEditorTheme.BORDER_MENU); + graphics.drawString(font, "Lua Source", 8, 9, ComputedEditorTheme.TEXT_HEADER, false); + button(graphics, divider - 292, 5, 42, "Copy", mouseX, mouseY, false); + button(graphics, divider - 247, 5, 42, "Paste", mouseX, mouseY, false); + button(graphics, divider - 202, 5, 42, "Save", mouseX, mouseY, false); + button(graphics, divider - 157, 5, 42, "File", mouseX, mouseY, false); + button(graphics, divider - 112, 5, 50, "Reset", mouseX, mouseY, false); + button( + graphics, + divider - 59, + 5, + 53, + replacementConfirmation ? "Replace" : "Apply", + mouseX, + mouseY, + replacementConfirmation); + graphics.drawString(font, "Live Preview", divider + 8, 9, ComputedEditorTheme.TEXT_HEADER, false); + } + + private void renderSource(GuiGraphics graphics, int divider) { + String[] lines = source.split("\n", -1); + int visible = visibleSourceLines(); + int cursorLine = lineOf(cursor); + int cursorColumn = cursor - lineStart(cursor); + if (revealCursor) { + ensureCursorVisible(divider, lines, cursorLine, cursorColumn); + revealCursor = false; + } else { + firstLine = Mth.clamp(firstLine, 0, maximumVerticalScroll()); + horizontalScroll = + Mth.clamp(horizontalScroll, 0, maximumHorizontalScroll(divider)); + } + for (int lineIndex = firstLine; + lineIndex < Math.min(lines.length, firstLine + visible); + lineIndex++) { + int y = SOURCE_TEXT_Y + (lineIndex - firstLine) * SOURCE_LINE_HEIGHT; + graphics.drawString( + font, + Integer.toString(lineIndex + 1), + 4, + y, + lineIndex == cursorLine + ? ComputedEditorTheme.ACCENT + : ComputedEditorTheme.TEXT_TERTIARY, + false); + } + graphics.enableScissor(37, SOURCE_TOP, divider - 10, height - SOURCE_BOTTOM_RESERVE); + for (int lineIndex = firstLine; + lineIndex < Math.min(lines.length, firstLine + visible); + lineIndex++) { + int y = SOURCE_TEXT_Y + (lineIndex - firstLine) * SOURCE_LINE_HEIGHT; + String line = lines[lineIndex]; + int x = SOURCE_TEXT_X - horizontalScroll; + List spans = lineIndex < highlightedLines.size() + ? highlightedLines.get(lineIndex) + : List.of(new LuaSyntaxHighlighter.Span(line, LuaSyntaxHighlighter.DEFAULT)); + for (LuaSyntaxHighlighter.Span span : spans) { + graphics.drawString(font, span.text(), x, y, span.color(), false); + x += font.width(span.text()); + } + if (sourceFocused + && lineIndex == cursorLine + && (System.currentTimeMillis() / 500) % 2 == 0) { + int cursorX = SOURCE_TEXT_X + - horizontalScroll + + font.width(line.substring(0, Math.min(cursorColumn, line.length()))); + graphics.vLine(cursorX, y - 1, y + 9, ComputedEditorTheme.TEXT_HEADER); + } + } + graphics.disableScissor(); + graphics.vLine(36, 26, height - 1, ComputedEditorTheme.BORDER_SUBTLE); + renderSourceScrollbars(graphics, divider); + String help = signatureHelp(); + if (!help.isEmpty()) { + graphics.fill(SOURCE_TEXT_X, height - 30, divider - 6, height - 16, 0xEE1A1A1A); + graphics.drawString(font, help, 47, height - 27, ComputedEditorTheme.TEXT_SECONDARY, false); + } + } + + private void renderPreview( + GuiGraphics graphics, + int divider, + int mouseX, + int mouseY, + float partialTick) { + int previewX = divider + 12; + int previewY = 38; + int previewWidth = width - divider - 24; + int previewHeight = Math.max(80, height / 2 - 42); + graphics.fill( + previewX, + previewY, + previewX + previewWidth, + previewY + previewHeight, + 0xFF0B0E10); + graphics.renderOutline( + previewX, + previewY, + previewWidth, + previewHeight, + ComputedEditorTheme.BORDER_DEFAULT); + if (previewNode != null) { + graphics.enableScissor( + previewX + 1, + previewY + 1, + previewX + previewWidth - 1, + previewY + previewHeight - 1); + previewNode.render(graphics, mouseX, mouseY, partialTick); + graphics.disableScissor(); + } + if (session.snapshot().stalePreview()) { + graphics.fill( + previewX + 1, + previewY + 1, + previewX + previewWidth - 1, + previewY + previewHeight - 1, + 0x66000000); + graphics.drawCenteredString( + font, + "STALE PREVIEW", + previewX + previewWidth / 2, + previewY + previewHeight - 15, + ComputedEditorTheme.STATUS_WARNING_TEXT); + } + renderSamples(graphics, divider, previewY + previewHeight + 12, mouseX, mouseY); + } + + private void renderSamples( + GuiGraphics graphics, + int divider, + int startY, + int mouseX, + int mouseY) { + int x = divider + 12; + graphics.drawString(font, "Sample Inputs and Fields", x, startY, ComputedEditorTheme.TEXT_HEADER, false); + int row = 0; + for (Map.Entry entry : sampleInputs.entrySet()) { + sampleRow(graphics, x, startY + 14 + row++ * 15, "Input", entry.getKey(), entry.getValue()); + } + for (Map.Entry entry : sampleFields.entrySet()) { + sampleRow(graphics, x, startY + 14 + row++ * 15, "Field", entry.getKey(), entry.getValue()); + } + if (previewDefinition != null) { + for (String event : previewDefinition.eventHandlers().keySet()) { + int y = startY + 14 + row++ * 15; + graphics.fill(x, y, Math.min(width - 12, x + 160), y + 13, ComputedEditorTheme.BUTTON_BACKGROUND); + graphics.drawString(font, "Emit " + event, x + 4, y + 3, ComputedEditorTheme.TEXT_PRIMARY, false); + } + } + } + + private void sampleRow( + GuiGraphics graphics, + int x, + int y, + String kind, + String id, + LuaValue value) { + graphics.fill(x, y, width - 12, y + 13, ComputedEditorTheme.BACKGROUND_TERTIARY); + graphics.drawString( + font, + kind + " " + id + " = " + display(value), + x + 4, + y + 3, + ComputedEditorTheme.TEXT_PRIMARY, + false); + } + + private void renderDiagnostics(GuiGraphics graphics, int divider) { + int y = height - 15; + graphics.fill(0, y, width, height, ComputedEditorTheme.BACKGROUND_SECONDARY); + graphics.hLine(0, width, y, ComputedEditorTheme.BORDER_MENU); + var diagnostics = session.snapshot().diagnostics(); + String message = diagnostics.isEmpty() + ? status.isEmpty() ? "Valid Lua definition" : status + : diagnostics.getFirst().message(); + int color = diagnostics.isEmpty() + ? ComputedEditorTheme.ACCENT_MUTED + : ComputedEditorTheme.STATUS_ERROR_TEXT; + graphics.drawString(font, message, 6, y + 4, color, false); + } + + private void renderCompletion(GuiGraphics graphics, int divider) { + int shown = Math.min(7, COMPLETIONS.size()); + int x = 48; + int y = Math.min(height - shown * 14 - 34, 54 + (lineOf(cursor) - firstLine) * 11); + int width = Math.min(divider - x - 8, 330); + graphics.fill(x, y, x + width, y + shown * 14 + 2, 0xFA151515); + graphics.renderOutline(x, y, width, shown * 14 + 2, ComputedEditorTheme.BORDER_MENU); + int first = Mth.clamp(completionIndex - shown / 2, 0, COMPLETIONS.size() - shown); + for (int index = first; index < first + shown; index++) { + int rowY = y + 1 + (index - first) * 14; + if (index == completionIndex) { + graphics.fill(x + 1, rowY, x + width - 1, rowY + 14, ComputedEditorTheme.MENU_SELECTED); + } + graphics.drawString( + font, + COMPLETIONS.get(index), + x + 5, + rowY + 3, + ComputedEditorTheme.TEXT_PRIMARY, + false); + } + } + + private void updateCompilation(long now) { + if (!session.update(now)) { + return; + } + var snapshot = session.snapshot(); + if (snapshot.currentDefinition() == null) { + status = "Preview retained from the last valid definition"; + return; + } + previewDefinition = snapshot.currentDefinition(); + fileDefinitionId = previewDefinition.id(); + sampleInputs.clear(); + previewDefinition.inputs().forEach(input -> sampleInputs.put(input.id(), input.defaultValue())); + sampleFields.clear(); + previewDefinition.fields().forEach(field -> sampleFields.put(field.id(), field.defaultValue())); + try { + preview = new LuaLivePreview(source); + sampleInputs.forEach(preview::setInput); + sampleFields.forEach(preview::setField); + preview.run(); + LuaDefinitionSource definition = + LuaDefinitionSource.embedded(1, previewDefinition.id(), source); + ComputedProgramV3 previewProgram = new ComputedProgramV3( + 0, + new ComputedGraph(UUID.randomUUID(), List.of(), List.of()), + Map.of(definition.id(), definition), + Map.of(), + null); + previewNode = LuaEditorGraphAdapter.createEditorNode( + previewProgram, + definition.id(), + 0, + 0); + centerPreviewNode(); + status = "Preview updated"; + replacementConfirmation = false; + } catch (RuntimeException exception) { + status = "Preview unavailable: " + exception.getMessage(); + } + } + + private void apply() { + var snapshot = session.snapshot(); + LuaNodeDefinition definition = snapshot.currentDefinition(); + if (definition == null) { + status = "Fix diagnostics before applying"; + return; + } + LuaDefinitionSource replacement = LuaDefinitionSource.embedded(1, definition.id(), source); + String existingHash = parent.definitionHash(definition.id()); + if (!existingHash.isEmpty() + && !existingHash.equals(replacement.hash()) + && !replacementConfirmation) { + replacementConfirmation = true; + status = "Click Replace to confirm the changed definition"; + return; + } + parent.applyLuaSource( + source, + definition.id(), + creationMode, + placementX, + placementY); + status = "Applied; awaiting authoritative autosave"; + minecraft.setScreen(parent); + } + + private void resetPreview() { + if (preview == null) { + return; + } + try { + preview.reset(source); + sampleInputs.forEach(preview::setInput); + sampleFields.forEach(preview::setField); + preview.run(); + status = "Preview state reset"; + } catch (RuntimeException exception) { + status = "Reset failed: " + exception.getMessage(); + } + } + + private boolean handleSampleClick(double mouseX, double mouseY, int divider) { + int previewY = 38; + int previewHeight = Math.max(80, height / 2 - 42); + int startY = previewY + previewHeight + 12; + if (mouseX < divider + 12 || mouseY < startY + 14) { + return false; + } + int row = ((int) mouseY - startY - 14) / 15; + int index = 0; + for (Map.Entry entry : sampleInputs.entrySet()) { + if (index++ == row) { + LuaValue value = nextValue(entry.getValue()); + entry.setValue(value); + preview.setInput(entry.getKey(), value); + preview.run(); + return true; + } + } + for (Map.Entry entry : sampleFields.entrySet()) { + if (index++ == row) { + LuaValue value = nextValue(entry.getValue()); + entry.setValue(value); + preview.setField(entry.getKey(), value); + preview.run(); + return true; + } + } + if (previewDefinition != null) { + for (String event : previewDefinition.eventHandlers().keySet()) { + if (index++ == row) { + preview.event(event, LuaValue.ONE); + return true; + } + } + } + return false; + } + + private void replaceSource(String replacement) { + source = replacement == null ? "" : replacement; + cursor = source.length(); + firstLine = 0; + horizontalScroll = 0; + revealCursor = true; + changed(); + } + + private void loadClipboard() { + try { + replaceSource(LuaDefinitionClipboard.importSource(minecraft.keyboardHandler.getClipboard())); + status = "Lua source loaded from clipboard"; + } catch (IllegalArgumentException exception) { + status = exception.getMessage(); + } + } + + private void exportFile() { + var snapshot = session.snapshot(); + LuaNodeDefinition definition = snapshot.currentDefinition(); + if (definition == null) { + status = "Fix diagnostics before exporting"; + return; + } + try { + var sourceDefinition = LuaDefinitionSource.embedded(1, definition.id(), source); + var path = LuaDefinitionFiles.export( + net.neoforged.fml.loading.FMLPaths.CONFIGDIR.get(), + sourceDefinition); + fileDefinitionId = definition.id(); + status = "Saved " + path.getFileName(); + } catch (java.io.IOException | RuntimeException exception) { + status = "File export failed: " + exception.getMessage(); + } + } + + private void importFile() { + if (fileDefinitionId.isBlank()) { + status = "Compile a definition before loading its file"; + return; + } + String fileName = fileDefinitionId.replace(':', '_').replace('/', '_') + ".lua"; + try { + replaceSource(LuaDefinitionFiles.importSource( + net.neoforged.fml.loading.FMLPaths.CONFIGDIR.get(), + fileName)); + status = "Loaded " + fileName; + } catch (java.io.IOException | RuntimeException exception) { + status = "File import failed: " + exception.getMessage(); + } + } + + private void insert(String text) { + if (text == null || text.isEmpty()) { + return; + } + source = source.substring(0, cursor) + text + source.substring(cursor); + cursor += text.length(); + changed(); + revealCursor = true; + } + + private void changed() { + replacementConfirmation = false; + completionOpen = false; + revealCursor = true; + highlightedLines = LuaSyntaxHighlighter.highlight(source); + session.sourceChanged(source, net.minecraft.Util.getMillis()); + } + + private void centerPreviewNode() { + if (previewNode == null || width <= 0 || height <= 0) { + return; + } + int divider = Math.max(300, width * 3 / 5); + int previewX = divider + 12; + int previewY = 38; + int previewWidth = width - divider - 24; + int previewHeight = Math.max(80, height / 2 - 42); + previewNode.ensureLayoutUpToDate(); + previewNode.setPos( + previewX + (previewWidth - previewNode.getWidth()) / 2, + previewY + (previewHeight - previewNode.getHeight()) / 2); + } + + private void ensureCursorVisible( + int divider, + String[] lines, + int cursorLine, + int cursorColumn) { + if (cursorLine < 0 || cursorLine >= lines.length) { + return; + } + int visible = visibleSourceLines(); + if (cursorLine < firstLine) { + firstLine = cursorLine; + } else if (cursorLine >= firstLine + visible) { + firstLine = cursorLine - visible + 1; + } + firstLine = Mth.clamp(firstLine, 0, maximumVerticalScroll()); + String line = lines[cursorLine]; + int prefixWidth = + font.width(line.substring(0, Math.min(cursorColumn, line.length()))); + int viewportWidth = sourceViewportWidth(divider); + if (prefixWidth - horizontalScroll < 0) { + horizontalScroll = prefixWidth; + } else if (prefixWidth - horizontalScroll > viewportWidth) { + horizontalScroll = prefixWidth - viewportWidth; + } + horizontalScroll = Mth.clamp(horizontalScroll, 0, maximumHorizontalScroll(divider)); + } + + private int maximumHorizontalScroll(int divider) { + int widest = 0; + for (String line : source.split("\n", -1)) { + widest = Math.max(widest, font.width(line)); + } + return Math.max(0, widest - sourceViewportWidth(divider)); + } + + private int maximumVerticalScroll() { + return Math.max(0, source.split("\n", -1).length - visibleSourceLines()); + } + + private int visibleSourceLines() { + return Math.max(1, (height - 62) / SOURCE_LINE_HEIGHT); + } + + private static int sourceViewportWidth(int divider) { + return Math.max(1, divider - SOURCE_TEXT_X - 12); + } + + private static int scrollPixels(double amount) { + if (amount == 0) { + return 0; + } + int pixels = Math.max(1, (int) Math.round(Math.abs(amount) * 24.0)); + return amount < 0 ? -pixels : pixels; + } + + private static int scrollLines(double amount) { + if (amount == 0) { + return 0; + } + int lines = Math.max(1, (int) Math.round(Math.abs(amount) * 3.0)); + return amount < 0 ? -lines : lines; + } + + private void renderSourceScrollbars(GuiGraphics graphics, int divider) { + int maximumVertical = maximumVerticalScroll(); + if (maximumVertical > 0) { + int trackX = divider - 9; + int trackTop = SOURCE_TOP + 2; + int trackLength = verticalTrackLength(); + int thumbLength = verticalThumbLength(trackLength); + int thumbTop = trackTop + scrollThumbOffset(firstLine, maximumVertical, trackLength, thumbLength); + graphics.fill( + trackX, + trackTop, + trackX + SCROLLBAR_SIZE, + trackTop + trackLength, + ComputedEditorTheme.BACKGROUND_TERTIARY); + graphics.fill( + trackX, + thumbTop, + trackX + SCROLLBAR_SIZE, + thumbTop + thumbLength, + ComputedEditorTheme.BORDER_HIGHLIGHT); + } + + int maximumHorizontal = maximumHorizontalScroll(divider); + if (maximumHorizontal > 0) { + int trackX = SOURCE_TEXT_X - 2; + int trackY = height - HORIZONTAL_SCROLLBAR_Y_OFFSET; + int trackLength = horizontalTrackLength(divider); + int thumbLength = horizontalThumbLength(divider, trackLength); + int thumbX = trackX + + scrollThumbOffset( + horizontalScroll, + maximumHorizontal, + trackLength, + thumbLength); + graphics.fill( + trackX, + trackY, + trackX + trackLength, + trackY + SCROLLBAR_SIZE, + ComputedEditorTheme.BACKGROUND_TERTIARY); + graphics.fill( + thumbX, + trackY, + thumbX + thumbLength, + trackY + SCROLLBAR_SIZE, + ComputedEditorTheme.BORDER_HIGHLIGHT); + } + } + + private boolean beginScrollbarDrag(double mouseX, double mouseY, int divider) { + int maximumVertical = maximumVerticalScroll(); + if (maximumVertical > 0) { + int trackX = divider - 9; + int trackTop = SOURCE_TOP + 2; + int trackLength = verticalTrackLength(); + if (contains(mouseX, mouseY, trackX, trackTop, SCROLLBAR_SIZE, trackLength)) { + int thumbLength = verticalThumbLength(trackLength); + int thumbTop = + trackTop + scrollThumbOffset(firstLine, maximumVertical, trackLength, thumbLength); + scrollbarDragOffset = contains( + mouseX, + mouseY, + trackX, + thumbTop, + SCROLLBAR_SIZE, + thumbLength) + ? (int) mouseY - thumbTop + : thumbLength / 2; + draggingVerticalScrollbar = true; + setVerticalScrollFromThumb((int) mouseY - scrollbarDragOffset); + return true; + } + } + + int maximumHorizontal = maximumHorizontalScroll(divider); + if (maximumHorizontal > 0) { + int trackX = SOURCE_TEXT_X - 2; + int trackY = height - HORIZONTAL_SCROLLBAR_Y_OFFSET; + int trackLength = horizontalTrackLength(divider); + if (contains(mouseX, mouseY, trackX, trackY, trackLength, SCROLLBAR_SIZE)) { + int thumbLength = horizontalThumbLength(divider, trackLength); + int thumbX = trackX + + scrollThumbOffset( + horizontalScroll, + maximumHorizontal, + trackLength, + thumbLength); + scrollbarDragOffset = contains( + mouseX, + mouseY, + thumbX, + trackY, + thumbLength, + SCROLLBAR_SIZE) + ? (int) mouseX - thumbX + : thumbLength / 2; + draggingHorizontalScrollbar = true; + setHorizontalScrollFromThumb((int) mouseX - scrollbarDragOffset, divider); + return true; + } + } + return false; + } + + private void setVerticalScrollFromThumb(int thumbTop) { + int maximum = maximumVerticalScroll(); + int trackTop = SOURCE_TOP + 2; + int trackLength = verticalTrackLength(); + int thumbLength = verticalThumbLength(trackLength); + firstLine = scrollFromThumb(thumbTop - trackTop, maximum, trackLength, thumbLength); + } + + private void setHorizontalScrollFromThumb(int thumbX, int divider) { + int maximum = maximumHorizontalScroll(divider); + int trackX = SOURCE_TEXT_X - 2; + int trackLength = horizontalTrackLength(divider); + int thumbLength = horizontalThumbLength(divider, trackLength); + horizontalScroll = scrollFromThumb(thumbX - trackX, maximum, trackLength, thumbLength); + } + + private int verticalTrackLength() { + return Math.max(SCROLLBAR_MIN_THUMB, height - SOURCE_BOTTOM_RESERVE - SOURCE_TOP - 2); + } + + private static int horizontalTrackLength(int divider) { + return Math.max(SCROLLBAR_MIN_THUMB, divider - SOURCE_TEXT_X - 9); + } + + private int verticalThumbLength(int trackLength) { + int totalLines = source.split("\n", -1).length; + return proportionalThumb(trackLength, visibleSourceLines(), totalLines); + } + + private int horizontalThumbLength(int divider, int trackLength) { + int contentWidth = sourceViewportWidth(divider) + maximumHorizontalScroll(divider); + return proportionalThumb(trackLength, sourceViewportWidth(divider), contentWidth); + } + + private static int proportionalThumb(int trackLength, int viewportSize, int contentSize) { + if (contentSize <= 0) { + return trackLength; + } + return Mth.clamp( + (int) Math.round((double) trackLength * viewportSize / contentSize), + Math.min(SCROLLBAR_MIN_THUMB, trackLength), + trackLength); + } + + private static int scrollThumbOffset( + int scroll, + int maximumScroll, + int trackLength, + int thumbLength) { + int travel = trackLength - thumbLength; + if (maximumScroll <= 0 || travel <= 0) { + return 0; + } + return (int) Math.round((double) Mth.clamp(scroll, 0, maximumScroll) + * travel + / maximumScroll); + } + + private static int scrollFromThumb( + int thumbOffset, + int maximumScroll, + int trackLength, + int thumbLength) { + int travel = trackLength - thumbLength; + if (maximumScroll <= 0 || travel <= 0) { + return 0; + } + return (int) Math.round((double) Mth.clamp(thumbOffset, 0, travel) + * maximumScroll + / travel); + } + + private int columnAtPixel(String line, int targetPixel) { + if (targetPixel <= 0) { + return 0; + } + int width = 0; + for (int index = 0; index < line.length(); index++) { + int characterWidth = font.width(line.substring(index, index + 1)); + if (targetPixel < width + characterWidth / 2) { + return index; + } + width += characterWidth; + } + return line.length(); + } + + private void moveVertical(int direction) { + int column = cursor - lineStart(cursor); + int target = lineOf(cursor) + direction; + if (target < 0) { + cursor = 0; + return; + } + String[] lines = source.split("\n", -1); + if (target >= lines.length) { + cursor = source.length(); + return; + } + cursor = offsetAtLine(target, Math.min(column, lines[target].length())); + int visible = visibleSourceLines(); + firstLine = Mth.clamp(firstLine, Math.max(0, target - visible + 1), target); + } + + private int offsetAtLine(int targetLine, int column) { + String[] lines = source.split("\n", -1); + targetLine = Mth.clamp(targetLine, 0, Math.max(0, lines.length - 1)); + int offset = 0; + for (int line = 0; line < targetLine; line++) { + offset += lines[line].length() + 1; + } + return Math.min(source.length(), offset + Math.min(column, lines[targetLine].length())); + } + + private int lineOf(int offset) { + int line = 0; + for (int index = 0; index < Math.min(offset, source.length()); index++) { + if (source.charAt(index) == '\n') { + line++; + } + } + return line; + } + + private int lineStart(int offset) { + int newline = source.lastIndexOf('\n', Math.max(0, offset - 1)); + return newline < 0 ? 0 : newline + 1; + } + + private int lineEnd(int offset) { + int newline = source.indexOf('\n', offset); + return newline < 0 ? source.length() : newline; + } + + private String indentation() { + int start = lineStart(cursor); + int end = start; + while (end < source.length() && source.charAt(end) == ' ') { + end++; + } + return source.substring(start, end); + } + + private String signatureHelp() { + int start = Math.max(0, cursor - 80); + String prefix = source.substring(start, cursor); + if (prefix.contains("ctx:endpoint")) { + return "ctx:endpoint(id, target?) → safe endpoint proxy"; + } + if (prefix.contains("node:input")) { + return "node:input(id, type, options?)"; + } + if (prefix.contains("node:field")) { + return "node:field(id, fieldType, { default, min, max, choices, control, step, label })"; + } + if (prefix.contains("ctx:output")) { + return "ctx:output(id, value)"; + } + return ""; + } + + private static LuaValue nextValue(LuaValue value) { + if (value.isboolean()) { + return LuaValue.valueOf(!value.toboolean()); + } + if (value.isnumber()) { + return LuaValue.valueOf(value.todouble() + 1); + } + if (value.isstring()) { + return LuaValue.valueOf(value.tojstring() + "*"); + } + return value; + } + + private static String display(LuaValue value) { + if (value == null || value.isnil()) { + return "nil"; + } + return value.tojstring(); + } + + private static void button( + GuiGraphics graphics, + int x, + int y, + int width, + String label, + int mouseX, + int mouseY, + boolean danger) { + boolean hovered = contains(mouseX, mouseY, x, y, width, 18); + int color = danger + ? hovered ? ComputedEditorTheme.DANGER_HOVER : ComputedEditorTheme.DANGER_BACKGROUND + : hovered ? ComputedEditorTheme.BUTTON_HOVER : ComputedEditorTheme.BUTTON_BACKGROUND; + graphics.fill(x, y, x + width, y + 18, color); + graphics.renderOutline(x, y, width, 18, ComputedEditorTheme.BORDER_MENU); + graphics.drawCenteredString( + net.minecraft.client.Minecraft.getInstance().font, + label, + x + width / 2, + y + 5, + ComputedEditorTheme.TEXT_PRIMARY); + } + + private static boolean contains( + double mouseX, + double mouseY, + int x, + int y, + int width, + int height) { + return mouseX >= x && mouseX < x + width && mouseY >= y && mouseY < y + height; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/MonitorBlockEntityRenderer.java b/src/main/java/dev/propulsionteam/computed/client/MonitorBlockEntityRenderer.java index f6a1d56..f5933da 100644 --- a/src/main/java/dev/propulsionteam/computed/client/MonitorBlockEntityRenderer.java +++ b/src/main/java/dev/propulsionteam/computed/client/MonitorBlockEntityRenderer.java @@ -47,7 +47,7 @@ public void render(MonitorBlockEntity be, float partialTick, PoseStack pose, Mul int blocksW = be.getWidth(); int blocksH = be.getHeight(); - Direction facing = be.getDirection(); + Direction facing = be.getFront(); Direction right = be.getRight(); Direction gridDown = be.getDown(); diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/canvas/InertialViewport.java b/src/main/java/dev/propulsionteam/computed/client/editor/canvas/InertialViewport.java new file mode 100644 index 0000000..a837abc --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/canvas/InertialViewport.java @@ -0,0 +1,139 @@ +package dev.propulsionteam.computed.client.editor.canvas; + +public final class InertialViewport { + public static final float MIN_ZOOM = 0.1f; + public static final float MAX_ZOOM = 3.0f; + + private static final double PAN_FRICTION = 7.5; + private static final double ZOOM_FRICTION = 12.0; + private static final double STOP_EPSILON = 0.0001; + + private double panX; + private double panY; + private double panVelocityX; + private double panVelocityY; + private float zoom = 1.0f; + private double zoomVelocity; + private double zoomAnchorX; + private double zoomAnchorY; + private boolean panning; + + public void restore(double panX, double panY, float zoom) { + this.panX = panX; + this.panY = panY; + this.zoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM); + cancelMotion(); + } + + public void beginPan() { + panning = true; + panVelocityX = 0; + panVelocityY = 0; + } + + public void dragPan(double screenDeltaX, double screenDeltaY, double elapsedSeconds) { + double graphDeltaX = screenDeltaX / zoom; + double graphDeltaY = screenDeltaY / zoom; + panX += graphDeltaX; + panY += graphDeltaY; + double elapsed = clamp(elapsedSeconds, 1.0 / 240.0, 1.0 / 20.0); + double sampleX = graphDeltaX / elapsed; + double sampleY = graphDeltaY / elapsed; + panVelocityX = panVelocityX * 0.35 + sampleX * 0.65; + panVelocityY = panVelocityY * 0.35 + sampleY * 0.65; + } + + public void endPan() { + panning = false; + } + + public void addZoomImpulse( + double requestedDelta, + double anchorX, + double anchorY) { + zoomAnchorX = anchorX; + zoomAnchorY = anchorY; + zoomVelocity += requestedDelta * ZOOM_FRICTION; + } + + public void advance(double elapsedSeconds, int viewportWidth, int viewportHeight) { + double elapsed = clamp(elapsedSeconds, 0, 0.1); + if (!panning) { + double decay = Math.exp(-PAN_FRICTION * elapsed); + double travel = (1 - decay) / PAN_FRICTION; + panX += panVelocityX * travel; + panY += panVelocityY * travel; + panVelocityX *= decay; + panVelocityY *= decay; + if (Math.abs(panVelocityX) < STOP_EPSILON) { + panVelocityX = 0; + } + if (Math.abs(panVelocityY) < STOP_EPSILON) { + panVelocityY = 0; + } + } + if (zoomVelocity == 0 || elapsed == 0) { + return; + } + double decay = Math.exp(-ZOOM_FRICTION * elapsed); + double zoomTravel = zoomVelocity * (1 - decay) / ZOOM_FRICTION; + float previousZoom = zoom; + float nextZoom = clamp((float) (zoom + zoomTravel), MIN_ZOOM, MAX_ZOOM); + if (nextZoom != previousZoom) { + double centerX = viewportWidth / 2.0; + double centerY = viewportHeight / 2.0; + double anchoredGraphX = + (zoomAnchorX - centerX) / previousZoom + centerX - panX; + double anchoredGraphY = + (zoomAnchorY - centerY) / previousZoom + centerY - panY; + zoom = nextZoom; + panX = (zoomAnchorX - centerX) / zoom + centerX - anchoredGraphX; + panY = (zoomAnchorY - centerY) / zoom + centerY - anchoredGraphY; + } + zoomVelocity *= decay; + if (Math.abs(zoomVelocity) < STOP_EPSILON + || zoom == MIN_ZOOM + || zoom == MAX_ZOOM) { + zoomVelocity = 0; + } + } + + public void cancelMotion() { + panVelocityX = 0; + panVelocityY = 0; + zoomVelocity = 0; + panning = false; + } + + public double panX() { + return panX; + } + + public double panY() { + return panY; + } + + public float zoom() { + return zoom; + } + + public double graphX(double screenX, int viewportWidth) { + return (screenX - viewportWidth / 2.0) / zoom + + viewportWidth / 2.0 + - panX; + } + + public double graphY(double screenY, int viewportHeight) { + return (screenY - viewportHeight / 2.0) / zoom + + viewportHeight / 2.0 + - panY; + } + + private static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static double clamp(double value, double minimum, double maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorGraphAdapter.java b/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorGraphAdapter.java new file mode 100644 index 0000000..a2ece64 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorGraphAdapter.java @@ -0,0 +1,377 @@ +package dev.propulsionteam.computed.client.editor.canvas; + +import dev.propulsionteam.computed.graph.ComputedGraph; +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.GraphConnection; +import dev.propulsionteam.computed.graph.GraphNode; +import dev.propulsionteam.computed.graph.GraphPoint; +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import dev.propulsionteam.computed.graph.PortDirection; +import dev.propulsionteam.computed.graph.PortSnapshot; +import dev.propulsionteam.computed.internal.node.api.WConnection; +import dev.propulsionteam.computed.internal.node.api.WGraph; +import dev.propulsionteam.computed.internal.node.api.WNode; +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.node.BundledLuaLibrary; +import dev.propulsionteam.computed.lua.node.IntegrationLuaLibrary; +import dev.propulsionteam.computed.lua.node.LuaDefinitionLoader; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class LuaEditorGraphAdapter { + private LuaEditorGraphAdapter() {} + + public static WGraph toEditorGraph(ComputedProgramV3 program) { + Map definitions = new LinkedHashMap<>(BundledLuaLibrary.load()); + definitions.putAll(IntegrationLuaLibrary.load()); + definitions.putAll(program.library()); + Map nodes = new LinkedHashMap<>(); + WGraph graph = new WGraph(); + for (GraphNode node : program.rootGraph().nodes()) { + LuaDefinitionSource definitionSource = definitions.get(node.definitionId()); + LuaNodeDefinition definition = definition(definitionSource); + GraphNode editorSource = definitionSource != null + && definitionSource.origin() != LuaDefinitionSource.Origin.EMBEDDED + ? new GraphNode( + node.id(), + node.definitionId(), + definitionSource.hash(), + node.x(), + node.y(), + node.ports(), + node.fields()) + : node; + String title = definition == null ? node.definitionId() : definition.title(); + boolean stateBoundary = definition != null && !definition.stateDefaults().isEmpty(); + LuaEditorNode editorNode = new LuaEditorNode( + editorSource, + title, + stateBoundary, + definition == null ? "utility" : definition.category(), + definition == null + ? dev.propulsionteam.computed.lua.node.NodeStyle.STANDARD + : definition.style(), + definition); + nodes.put(node.id(), editorNode); + graph.addNode(editorNode); + } + for (GraphConnection connection : program.rootGraph().connections()) { + LuaEditorNode source = nodes.get(connection.sourceNode()); + LuaEditorNode target = nodes.get(connection.targetNode()); + if (source == null || target == null) { + continue; + } + int sourcePin = stablePin(source, true, connection.sourcePort()); + int targetPin = stablePin(target, false, connection.targetPort()); + if (sourcePin < 0 || targetPin < 0) { + continue; + } + int[] waypointXs = connection.waypoints().stream().mapToInt(point -> (int) Math.round(point.x())).toArray(); + int[] waypointYs = connection.waypoints().stream().mapToInt(point -> (int) Math.round(point.y())).toArray(); + graph.connect(new WConnection( + source.getId(), + sourcePin, + target.getId(), + targetPin, + waypointXs, + waypointYs, + connection.sourcePort(), + connection.targetPort())); + } + return graph; + } + + public static ComputedProgramV3 fromEditorGraph( + WGraph editor, + ComputedProgramV3 base, + long revision) { + Map baseNodes = new HashMap<>(); + base.rootGraph().nodes().forEach(node -> baseNodes.put(node.id(), node)); + List nodes = new ArrayList<>(); + for (WNode editorNode : editor.getNodes()) { + if (editorNode instanceof LuaEditorNode luaNode) { + nodes.add(luaNode.toGraphNode( + editorNode.getX(), + editorNode.getY(), + editorNode.getId())); + continue; + } + GraphNode original = baseNodes.get(editorNode.getId()); + if (original == null) { + continue; + } + nodes.add(new GraphNode( + original.id(), + original.definitionId(), + original.definitionHash(), + editorNode.getX(), + editorNode.getY(), + original.ports(), + original.fields())); + } + Map oldConnections = new HashMap<>(); + base.rootGraph().connections().forEach(connection -> + oldConnections.put(identity(connection), connection)); + List connections = new ArrayList<>(); + for (WConnection connection : editor.getConnections()) { + String sourcePort = stablePort(editor, connection.sourceNode(), true, connection.sourcePin()); + String targetPort = stablePort(editor, connection.targetNode(), false, connection.targetPin()); + if (sourcePort == null || targetPort == null) { + continue; + } + String identity = identity( + connection.sourceNode(), + sourcePort, + connection.targetNode(), + targetPort); + GraphConnection previous = oldConnections.get(identity); + List waypoints = new ArrayList<>(); + int[] xs = connection.waypointXs(); + int[] ys = connection.waypointYs(); + for (int index = 0; index < Math.min(xs.length, ys.length); index++) { + waypoints.add(new GraphPoint(xs[index], ys[index])); + } + connections.add(new GraphConnection( + previous == null ? UUID.randomUUID() : previous.id(), + connection.sourceNode(), + sourcePort, + connection.targetNode(), + targetPort, + waypoints)); + } + return new ComputedProgramV3( + revision, + new ComputedGraph(base.rootGraph().id(), nodes, connections), + base.library(), + base.persistentState(), + base.metadata()); + } + + public static LuaEditorNode createEditorNode( + ComputedProgramV3 program, + String definitionId, + int x, + int y) { + LuaDefinitionSource source = definitions(program).get(definitionId); + LuaNodeDefinition definition = definition(source); + if (source == null || definition == null) { + return null; + } + List ports = ports(definition); + Map fields = defaultFields(definition); + GraphNode node = new GraphNode( + UUID.randomUUID(), + source.id(), + source.hash(), + x, + y, + ports, + fields); + return new LuaEditorNode( + node, + definition.title(), + !definition.stateDefaults().isEmpty(), + definition.category(), + definition.style(), + definition); + } + + public static ComputedProgramV3 replaceDefinition( + ComputedProgramV3 program, + LuaDefinitionSource replacement) { + LuaNodeDefinition definition = definition(replacement); + if (definition == null || !definition.id().equals(replacement.id())) { + throw new IllegalArgumentException("Lua source does not return the replacement definition ID"); + } + Map library = new LinkedHashMap<>(program.library()); + library.put(replacement.id(), replacement); + List nextPorts = ports(definition); + Map nextPortIndex = new HashMap<>(); + nextPorts.forEach(port -> nextPortIndex.put(port.direction() + "\u0000" + port.id(), port)); + List nodes = new ArrayList<>(); + Set replacedNodes = new java.util.HashSet<>(); + for (GraphNode node : program.rootGraph().nodes()) { + if (!node.definitionId().equals(replacement.id())) { + nodes.add(node); + continue; + } + replacedNodes.add(node.id()); + Map fields = defaultFields(definition); + node.fields().forEach((id, value) -> { + if (fields.containsKey(id)) { + fields.put(id, value); + } + }); + nodes.add(new GraphNode( + node.id(), + replacement.id(), + replacement.hash(), + node.x(), + node.y(), + nextPorts, + fields)); + } + Map nodeIndex = new HashMap<>(); + nodes.forEach(node -> nodeIndex.put(node.id(), node)); + List connections = program.rootGraph().connections().stream() + .filter(connection -> compatible(connection, nodeIndex, nextPortIndex, replacedNodes)) + .toList(); + Map state = + new LinkedHashMap<>(program.persistentState()); + replacedNodes.forEach(state::remove); + return new ComputedProgramV3( + program.revision(), + new ComputedGraph(program.rootGraph().id(), nodes, connections), + library, + state, + program.metadata()); + } + + public static ComputedProgramV3 addDefinitionAndNode( + ComputedProgramV3 program, + LuaDefinitionSource definition, + int x, + int y) { + ComputedProgramV3 updated = replaceDefinition(program, definition); + LuaEditorNode editorNode = createEditorNode(updated, definition.id(), x, y); + if (editorNode == null) { + throw new IllegalArgumentException("Lua definition could not create an editor node"); + } + List nodes = new ArrayList<>(updated.rootGraph().nodes()); + nodes.add(editorNode.source()); + return new ComputedProgramV3( + updated.revision(), + new ComputedGraph( + updated.rootGraph().id(), + nodes, + updated.rootGraph().connections()), + updated.library(), + updated.persistentState(), + updated.metadata()); + } + + public static LuaEditorNode duplicateEditorNode(LuaEditorNode source, int x, int y) { + GraphNode original = source.source(); + GraphNode duplicate = new GraphNode( + UUID.randomUUID(), + original.definitionId(), + original.definitionHash(), + x, + y, + original.ports(), + original.fields()); + return new LuaEditorNode( + duplicate, + source.getTitle(), + source.isStateBoundary(), + source.category(), + source.style(), + source.definition()); + } + + public static Map definitions(ComputedProgramV3 program) { + Map definitions = new LinkedHashMap<>(BundledLuaLibrary.load()); + definitions.putAll(IntegrationLuaLibrary.load()); + definitions.putAll(program.library()); + return java.util.Collections.unmodifiableMap(definitions); + } + + private static List ports(LuaNodeDefinition definition) { + List ports = new ArrayList<>(); + definition.inputs().forEach(port -> ports.add(new PortSnapshot( + port.id(), + PortDirection.INPUT, + port.type(), + port.id()))); + definition.outputs().forEach(port -> ports.add(new PortSnapshot( + port.id(), + PortDirection.OUTPUT, + port.type(), + port.id()))); + return List.copyOf(ports); + } + + private static Map defaultFields( + LuaNodeDefinition definition) { + Map fields = new LinkedHashMap<>(); + LuaStateCodec codec = new LuaStateCodec(); + definition.fields().forEach(field -> fields.put(field.id(), codec.encode(field.defaultValue()))); + return fields; + } + + private static boolean compatible( + GraphConnection connection, + Map nodes, + Map replacementPorts, + Set replacedNodes) { + GraphNode source = nodes.get(connection.sourceNode()); + GraphNode target = nodes.get(connection.targetNode()); + if (source == null || target == null) { + return false; + } + PortSnapshot sourcePort = port(source, PortDirection.OUTPUT, connection.sourcePort()); + PortSnapshot targetPort = port(target, PortDirection.INPUT, connection.targetPort()); + return sourcePort != null && targetPort != null && sourcePort.type() == targetPort.type(); + } + + private static PortSnapshot port(GraphNode node, PortDirection direction, String id) { + return node.ports().stream() + .filter(port -> port.direction() == direction && port.id().equals(id)) + .findFirst() + .orElse(null); + } + + private static LuaNodeDefinition definition(LuaDefinitionSource source) { + if (source == null) { + return null; + } + try { + var compiled = new LuaSourceCompiler().compile(source.apiVersion(), source.source()); + return new LuaDefinitionLoader().load(compiled, new LuaSandbox()); + } catch (RuntimeException exception) { + return null; + } + } + + private static int stablePin(WNode node, boolean output, String key) { + var pins = output ? node.getOutputs() : node.getInputs(); + for (int index = 0; index < pins.size(); index++) { + if (key.equals(pins.get(index).getStableKey())) { + return index; + } + } + return -1; + } + + private static String stablePort(WGraph graph, UUID nodeId, boolean output, int index) { + WNode node = graph.getNode(nodeId); + if (node == null) { + return null; + } + var pins = output ? node.getOutputs() : node.getInputs(); + if (index < 0 || index >= pins.size()) { + return null; + } + return pins.get(index).getStableKey(); + } + + private static String identity(GraphConnection connection) { + return identity( + connection.sourceNode(), + connection.sourcePort(), + connection.targetNode(), + connection.targetPort()); + } + + private static String identity(UUID source, String sourcePort, UUID target, String targetPort) { + return source + "\u0000" + sourcePort + "\u0000" + target + "\u0000" + targetPort; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorNode.java b/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorNode.java new file mode 100644 index 0000000..3287d33 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorNode.java @@ -0,0 +1,333 @@ +package dev.propulsionteam.computed.client.editor.canvas; + +import dev.propulsionteam.computed.graph.GraphNode; +import dev.propulsionteam.computed.graph.PortDirection; +import dev.propulsionteam.computed.graph.PortSnapshot; +import dev.propulsionteam.computed.client.renderer.node.BedrockNodeRenderer; +import dev.propulsionteam.computed.client.renderer.node.NodePalette; +import dev.propulsionteam.computed.client.renderer.node.NodeRenderLayout; +import dev.propulsionteam.computed.internal.node.api.WNode; +import dev.propulsionteam.computed.internal.node.api.WPin; +import dev.propulsionteam.computed.lua.node.ConnectionType; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.node.NodeStyle; +import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; + +public final class LuaEditorNode extends WNode { + private final GraphNode source; + private final boolean stateBoundary; + private final String category; + private final NodeStyle style; + private final LuaNodeDefinition definition; + private final List fieldControls = new ArrayList<>(); + private final LuaStateCodec fieldCodec = new LuaStateCodec(); + + public LuaEditorNode( + GraphNode source, + String title, + boolean stateBoundary, + String category, + NodeStyle style, + LuaNodeDefinition definition) { + super(ResourceLocation.parse(source.definitionId()), title, source.x(), source.y()); + this.source = source; + this.stateBoundary = stateBoundary; + this.category = category == null ? "utility" : category; + this.style = style == null ? NodeStyle.STANDARD : style; + this.definition = definition; + for (PortSnapshot port : source.ports()) { + WPin.DataType dataType = dataType(port.type()); + int color = color(port.type()); + if (port.direction() == PortDirection.INPUT) { + addInput(port.id(), port.label(), dataType, color); + } else { + addOutput(port.id(), port.label(), dataType, color); + } + } + CompoundTag identity = new CompoundTag(); + identity.putString("id", source.id().toString()); + identity.putString("title", title); + identity.putInt("x", source.x()); + identity.putInt("y", source.y()); + load(identity); + if (definition != null) { + Map encodedFields = source.fields(); + definition.fields().forEach(schema -> { + CompoundTag encoded = encodedFields.get(schema.id()); + org.luaj.vm2.LuaValue value = encoded == null + ? schema.defaultValue() + : fieldCodec.decode(encoded); + fieldControls.add(new LuaNodeFieldControl(schema, value)); + }); + } + updateLayout(); + } + + public GraphNode source() { + return toGraphNode(getX(), getY(), source.id()); + } + + public String category() { + return category; + } + + public NodeStyle style() { + return style; + } + + LuaNodeDefinition definition() { + return definition; + } + + public org.luaj.vm2.LuaValue fieldValue(String id) { + return fieldControls.stream() + .filter(control -> control.schema().id().equals(id)) + .map(LuaNodeFieldControl::value) + .findFirst() + .orElse(org.luaj.vm2.LuaValue.NIL); + } + + public boolean setFieldValue(String id, org.luaj.vm2.LuaValue value) { + for (int index = 0; index < fieldControls.size(); index++) { + LuaNodeFieldControl control = fieldControls.get(index); + if (control.schema().id().equals(id)) { + fieldControls.set(index, new LuaNodeFieldControl(control.schema(), value)); + clearHiddenFocus(); + updateLayout(); + return true; + } + } + return false; + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + BedrockNodeRenderer.render(graphics, this, category, false, false, mouseX, mouseY); + int accent = NodePalette.category(category).frameArgb(); + int fieldsY = getY() + fieldsTop(); + List visible = visibleFieldControls(); + for (int index = 0; index < visible.size(); index++) { + visible.get(index).render( + graphics, + getX(), + fieldsY + index * LuaNodeFieldControl.ROW_HEIGHT, + getWidth(), + mouseX, + mouseY, + accent); + } + } + + @Override + public void updateLayout() { + if (definition == null) { + super.updateLayout(); + return; + } + NodeRenderLayout layout = NodeRenderLayout.measure( + definition, + visibleFieldControls().stream().map(LuaNodeFieldControl::schema).toList()); + setMeasuredSize(layout.width(), layout.height()); + } + + @Override + public boolean hasInteractiveElementAt(double mouseX, double mouseY) { + List visible = visibleFieldControls(); + if (visible.stream().anyMatch(LuaNodeFieldControl::focused)) { + return true; + } + int first = fieldsTop(); + return !visible.isEmpty() + && mouseX >= 6 + && mouseX < getWidth() - 6 + && mouseY >= first + && mouseY < first + visible.size() * LuaNodeFieldControl.ROW_HEIGHT; + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + int first = fieldsTop(); + List visible = visibleFieldControls(); + for (int index = 0; index < visible.size(); index++) { + LuaNodeFieldControl control = visible.get(index); + if (control.mouseClicked( + mouseX, + mouseY, + button, + getWidth(), + first + index * LuaNodeFieldControl.ROW_HEIGHT)) { + fieldControls.stream() + .filter(other -> other != control) + .forEach(LuaNodeFieldControl::clearFocus); + clearHiddenFocus(); + updateLayout(); + return true; + } + } + clearElementFocus(); + return false; + } + + @Override + public boolean mouseDragged( + double mouseX, + double mouseY, + int button, + double dragX, + double dragY) { + for (LuaNodeFieldControl control : visibleFieldControls()) { + if (control.mouseDragged(mouseX, button, getWidth())) { + return true; + } + } + return false; + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + for (LuaNodeFieldControl control : visibleFieldControls()) { + if (control.mouseReleased(button)) { + return true; + } + } + return false; + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + for (LuaNodeFieldControl control : visibleFieldControls()) { + if (control.keyPressed(keyCode)) { + return true; + } + } + return false; + } + + @Override + public boolean charTyped(char codePoint, int modifiers) { + for (LuaNodeFieldControl control : visibleFieldControls()) { + if (control.charTyped(codePoint)) { + return true; + } + } + return false; + } + + @Override + public boolean hasFocusedElement() { + return visibleFieldControls().stream().anyMatch(LuaNodeFieldControl::focused); + } + + @Override + public void clearElementFocus() { + fieldControls.forEach(LuaNodeFieldControl::clearFocus); + } + + @Override + public CompoundTag save() { + CompoundTag tag = super.save(); + CompoundTag fields = new CompoundTag(); + fieldControls.forEach(control -> + fields.put(control.schema().id(), fieldCodec.encode(control.value()))); + tag.put("luaFields", fields); + return tag; + } + + @Override + public void load(CompoundTag tag) { + super.load(tag); + if (!tag.contains("luaFields") || fieldControls.isEmpty()) { + return; + } + CompoundTag fields = tag.getCompound("luaFields"); + Map byId = new LinkedHashMap<>(); + fieldControls.forEach(control -> byId.put(control.schema().id(), control)); + for (String id : fields.getAllKeys()) { + LuaNodeFieldControl current = byId.get(id); + if (current == null) { + continue; + } + int index = fieldControls.indexOf(current); + fieldControls.set(index, new LuaNodeFieldControl( + current.schema(), + fieldCodec.decode(fields.getCompound(id)))); + } + } + + @Override + public boolean isStateBoundary() { + return stateBoundary; + } + + public GraphNode toGraphNode(int x, int y, UUID id) { + Map fields = new LinkedHashMap<>(); + fieldControls.forEach(control -> + fields.put(control.schema().id(), fieldCodec.encode(control.value()))); + if (fieldControls.isEmpty()) { + fields.putAll(source.fields()); + } + return new GraphNode( + id, + source.definitionId(), + source.definitionHash(), + x, + y, + source.ports(), + fields); + } + + private int fieldsTop() { + int portRows = Math.max(getInputs().size(), getOutputs().size()); + return 20 + portRows * 12 + (portRows == 0 ? 0 : 4); + } + + private List visibleFieldControls() { + return fieldControls.stream().filter(this::isVisible).toList(); + } + + private boolean isVisible(LuaNodeFieldControl control) { + String controllingId = control.schema().visibleWhenField(); + if (controllingId == null) { + return true; + } + org.luaj.vm2.LuaValue controllingValue = fieldControls.stream() + .filter(candidate -> candidate.schema().id().equals(controllingId)) + .map(LuaNodeFieldControl::value) + .findFirst() + .orElse(org.luaj.vm2.LuaValue.NIL); + return control.schema().visibleWhenValue().equals(controllingValue.tojstring()); + } + + private void clearHiddenFocus() { + fieldControls.stream() + .filter(control -> !isVisible(control)) + .forEach(LuaNodeFieldControl::clearFocus); + } + + private static WPin.DataType dataType(ConnectionType type) { + return switch (type) { + case STRING -> WPin.DataType.STRING; + case WIDGET, TABLE -> WPin.DataType.WIDGET; + case NUMBER, BOOLEAN, EVENT -> WPin.DataType.NUMBER; + }; + } + + private static int color(ConnectionType type) { + return switch (type) { + case NUMBER -> 0xFF4E86E8; + case BOOLEAN -> 0xFF985AD6; + case STRING -> 0xFFD653B5; + case EVENT -> 0xFF27C7D9; + case WIDGET -> 0xFF9BCB45; + case TABLE -> 0xFF8A9099; + }; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaNodeFieldControl.java b/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaNodeFieldControl.java new file mode 100644 index 0000000..214aade --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/canvas/LuaNodeFieldControl.java @@ -0,0 +1,408 @@ +package dev.propulsionteam.computed.client.editor.canvas; + +import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; +import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; +import dev.propulsionteam.computed.internal.node.client.ui.WNodeScreen; +import dev.propulsionteam.computed.lua.node.FieldControl; +import dev.propulsionteam.computed.lua.node.FieldType; +import dev.propulsionteam.computed.lua.node.LuaFieldSchema; +import dev.propulsionteam.computed.lua.node.LuaFieldValues; +import java.util.List; +import java.util.Locale; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.registries.BuiltInRegistries; +import org.lwjgl.glfw.GLFW; +import org.luaj.vm2.LuaValue; + +final class LuaNodeFieldControl { + static final int ROW_HEIGHT = 18; + static final int CONTROL_WIDTH = 76; + + private static final List DIRECTIONS = + List.of("front", "back", "left", "right", "up", "down"); + + private final LuaFieldSchema schema; + private LuaValue value; + private String editBuffer = ""; + private boolean focused; + private boolean expanded; + private boolean dragging; + + LuaNodeFieldControl(LuaFieldSchema schema, LuaValue value) { + this.schema = schema; + this.value = LuaFieldValues.normalize(schema, value); + } + + LuaFieldSchema schema() { + return schema; + } + + LuaValue value() { + return value; + } + + void render( + GuiGraphics graphics, + int nodeX, + int rowY, + int nodeWidth, + int mouseX, + int mouseY, + int accent) { + var font = Minecraft.getInstance().font; + int controlX = nodeX + nodeWidth - CONTROL_WIDTH - 9; + int controlY = rowY + 2; + int controlHeight = 14; + graphics.drawString( + font, + schema.label(), + nodeX + 10, + rowY + 5, + ComputedEditorTheme.TEXT_SECONDARY, + false); + boolean hovered = mouseX >= controlX + && mouseX < controlX + CONTROL_WIDTH + && mouseY >= controlY + && mouseY < controlY + controlHeight; + if (schema.type() == FieldType.BOOLEAN) { + renderBoolean(graphics, controlX, controlY, hovered, accent); + } else if (schema.type() == FieldType.NUMBER + && schema.control() == FieldControl.SLIDER) { + renderSlider(graphics, controlX, controlY, hovered, accent); + } else if (schema.type() == FieldType.CHOICE + || schema.type() == FieldType.DIRECTION) { + renderDropdown(graphics, controlX, controlY, hovered, accent); + } else { + renderValueBox(graphics, controlX, controlY, hovered, accent); + } + if (expanded) { + renderDropdownOverlay( + graphics, + controlX, + controlY + controlHeight, + mouseX, + mouseY, + accent); + } + } + + boolean mouseClicked( + double localX, + double localY, + int button, + int nodeWidth, + int rowY) { + int controlX = nodeWidth - CONTROL_WIDTH - 9; + int controlY = rowY + 2; + List options = options(); + if (expanded) { + int option = (int) ((localY - controlY - 14) / 14); + if (localX >= controlX + && localX < controlX + CONTROL_WIDTH + && option >= 0 + && option < options.size()) { + value = LuaValue.valueOf(options.get(option)); + expanded = false; + focused = false; + return true; + } + expanded = false; + focused = false; + return true; + } + if (button != 0 + || localX < controlX + || localX >= controlX + CONTROL_WIDTH + || localY < controlY + || localY >= controlY + 14) { + if (focused) { + commitBuffer(); + focused = false; + dragging = false; + return true; + } + focused = false; + dragging = false; + return false; + } + if (schema.type() == FieldType.BOOLEAN) { + value = LuaValue.valueOf(!value.toboolean()); + return true; + } + if (schema.type() == FieldType.NUMBER + && schema.control() == FieldControl.SLIDER) { + dragging = true; + setSlider(localX, controlX); + return true; + } + if (schema.type() == FieldType.CHOICE + || schema.type() == FieldType.DIRECTION) { + expanded = true; + focused = true; + return true; + } + if (schema.type() == FieldType.ITEM) { + focused = false; + WNodeScreen.requestItemPick(stack -> value = LuaValue.valueOf( + BuiltInRegistries.ITEM.getKey(stack.getItem()).toString())); + return true; + } + focused = true; + editBuffer = displayValue(); + return true; + } + + boolean mouseDragged(double localX, int button, int nodeWidth) { + if (!dragging || button != 0) { + return false; + } + setSlider(localX, nodeWidth - CONTROL_WIDTH - 9); + return true; + } + + boolean mouseReleased(int button) { + if (button != 0 || !dragging) { + return false; + } + dragging = false; + return true; + } + + boolean keyPressed(int keyCode) { + if (!focused) { + return false; + } + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + focused = false; + expanded = false; + return true; + } + if (expanded) { + return true; + } + if (Screen.hasControlDown() && keyCode == GLFW.GLFW_KEY_A) { + editBuffer = ""; + return true; + } + if (Screen.hasControlDown() && keyCode == GLFW.GLFW_KEY_C) { + Minecraft.getInstance().keyboardHandler.setClipboard(editBuffer); + return true; + } + if (Screen.hasControlDown() && keyCode == GLFW.GLFW_KEY_V) { + editBuffer += Minecraft.getInstance().keyboardHandler.getClipboard(); + return true; + } + if (keyCode == GLFW.GLFW_KEY_BACKSPACE && !editBuffer.isEmpty()) { + editBuffer = editBuffer.substring(0, editBuffer.length() - 1); + return true; + } + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + commitBuffer(); + focused = false; + return true; + } + return true; + } + + boolean charTyped(char character) { + if (!focused || expanded || Character.isISOControl(character)) { + return false; + } + if (editBuffer.length() < 256) { + editBuffer += character; + } + return true; + } + + boolean focused() { + return focused || expanded || dragging; + } + + void clearFocus() { + if (focused && !expanded) { + commitBuffer(); + } + focused = false; + expanded = false; + dragging = false; + } + + int overlayBottom(int rowY) { + return expanded ? rowY + 16 + options().size() * 14 : rowY + ROW_HEIGHT; + } + + private void renderBoolean( + GuiGraphics graphics, int x, int y, boolean hovered, int accent) { + ComputedEditorStyle.drawField( + graphics, x, y, CONTROL_WIDTH, 14, focused, hovered, accent); + int boxX = x + CONTROL_WIDTH - 13; + graphics.renderOutline(boxX, y + 2, 10, 10, ComputedEditorTheme.BORDER_DEFAULT); + if (value.toboolean()) { + graphics.fill(boxX + 2, y + 4, boxX + 8, y + 10, accent); + } + graphics.drawString( + Minecraft.getInstance().font, + value.toboolean() ? "On" : "Off", + x + 4, + y + 3, + ComputedEditorTheme.TEXT_PRIMARY, + false); + } + + private void renderSlider( + GuiGraphics graphics, int x, int y, boolean hovered, int accent) { + ComputedEditorStyle.drawField( + graphics, x, y, CONTROL_WIDTH, 14, dragging, hovered, accent); + double minimum = schema.minimum(); + double maximum = schema.maximum(); + double ratio = (value.todouble() - minimum) / (maximum - minimum); + int trackX = x + 4; + int trackWidth = CONTROL_WIDTH - 8; + graphics.fill(trackX, y + 9, trackX + trackWidth, y + 11, ComputedEditorTheme.BORDER_SUBTLE); + int knob = trackX + (int) Math.round(ratio * trackWidth); + graphics.fill(knob - 2, y + 7, knob + 2, y + 13, accent); + graphics.drawString( + Minecraft.getInstance().font, + formatNumber(value.todouble()), + x + 4, + y + 2, + ComputedEditorTheme.TEXT_PRIMARY, + false); + } + + private void renderDropdown( + GuiGraphics graphics, int x, int y, boolean hovered, int accent) { + ComputedEditorStyle.drawField( + graphics, x, y, CONTROL_WIDTH, 14, expanded, hovered, accent); + graphics.drawString( + Minecraft.getInstance().font, + value.tojstring(), + x + 4, + y + 3, + ComputedEditorTheme.TEXT_PRIMARY, + false); + graphics.drawString( + Minecraft.getInstance().font, + expanded ? "▴" : "▾", + x + CONTROL_WIDTH - 10, + y + 3, + ComputedEditorTheme.TEXT_SECONDARY, + false); + } + + private void renderValueBox( + GuiGraphics graphics, int x, int y, boolean hovered, int accent) { + ComputedEditorStyle.drawField( + graphics, x, y, CONTROL_WIDTH, 14, focused, hovered, accent); + if (schema.type() == FieldType.COLOR) { + graphics.fill(x + 2, y + 2, x + 13, y + 12, (int) (long) value.todouble()); + } + String display = focused ? editBuffer + "_" : displayValue(); + int textX = schema.type() == FieldType.COLOR ? x + 16 : x + 4; + String visible = Minecraft.getInstance().font.plainSubstrByWidth( + display, + x + CONTROL_WIDTH - 3 - textX); + graphics.drawString( + Minecraft.getInstance().font, + visible, + textX, + y + 3, + ComputedEditorTheme.TEXT_PRIMARY, + false); + } + + private void renderDropdownOverlay( + GuiGraphics graphics, + int x, + int y, + int mouseX, + int mouseY, + int accent) { + List options = options(); + graphics.pose().pushPose(); + graphics.pose().translate(0, 0, 4500); + graphics.fill( + x, + y, + x + CONTROL_WIDTH, + y + options.size() * 14, + ComputedEditorTheme.MENU_BACKGROUND); + graphics.renderOutline( + x, + y, + CONTROL_WIDTH, + options.size() * 14, + accent); + for (int index = 0; index < options.size(); index++) { + int rowY = y + index * 14; + boolean hovered = mouseX >= x + && mouseX < x + CONTROL_WIDTH + && mouseY >= rowY + && mouseY < rowY + 14; + if (hovered) { + graphics.fill( + x + 1, + rowY, + x + CONTROL_WIDTH - 1, + rowY + 14, + 0x66000000 | (accent & 0x00FFFFFF)); + } + graphics.drawString( + Minecraft.getInstance().font, + options.get(index), + x + 4, + rowY + 3, + ComputedEditorTheme.TEXT_PRIMARY, + false); + } + graphics.pose().popPose(); + } + + private void setSlider(double localX, int controlX) { + double ratio = Math.max(0, Math.min(1, (localX - controlX - 4) / (CONTROL_WIDTH - 8.0))); + double number = schema.minimum() + ratio * (schema.maximum() - schema.minimum()); + value = LuaFieldValues.normalize(schema, LuaValue.valueOf(number)); + } + + private void commitBuffer() { + try { + LuaValue parsed = switch (schema.type()) { + case NUMBER -> LuaValue.valueOf(Double.parseDouble(editBuffer.replace(',', '.'))); + case COLOR -> LuaValue.valueOf((double) parseColor(editBuffer)); + default -> LuaValue.valueOf(editBuffer); + }; + value = LuaFieldValues.normalize(schema, parsed); + } catch (RuntimeException ignored) { + editBuffer = displayValue(); + } + } + + private String displayValue() { + return switch (schema.type()) { + case NUMBER -> formatNumber(value.todouble()); + case COLOR -> String.format(Locale.ROOT, "%08X", (long) value.todouble()); + default -> value.tojstring(); + }; + } + + private List options() { + return schema.type() == FieldType.CHOICE ? schema.choices() : DIRECTIONS; + } + + private static long parseColor(String text) { + String normalized = text.strip(); + if (normalized.startsWith("#")) { + normalized = normalized.substring(1); + } + return Long.parseUnsignedLong(normalized, 16) & 0xffffffffL; + } + + private static String formatNumber(double value) { + if (value == Math.rint(value)) { + return Long.toString((long) value); + } + return String.format(Locale.ROOT, "%.4f", value).replaceAll("0+$", "").replaceAll("\\.$", ""); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/explorer/ExplorerNode.java b/src/main/java/dev/propulsionteam/computed/client/editor/explorer/ExplorerNode.java new file mode 100644 index 0000000..af87873 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/explorer/ExplorerNode.java @@ -0,0 +1,28 @@ +package dev.propulsionteam.computed.client.editor.explorer; + +import java.util.List; + +public record ExplorerNode( + String id, + String title, + Ownership ownership, + List folderPath, + boolean available, + String unavailableReason) { + + public ExplorerNode { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("Explorer node id is required"); + } + title = title == null || title.isBlank() ? id : title; + ownership = ownership == null ? Ownership.BUNDLED : ownership; + folderPath = folderPath == null ? List.of() : List.copyOf(folderPath); + unavailableReason = unavailableReason == null ? "" : unavailableReason; + } + + public enum Ownership { + BUNDLED, + INTEGRATION, + USER + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/explorer/ExplorerRow.java b/src/main/java/dev/propulsionteam/computed/client/editor/explorer/ExplorerRow.java new file mode 100644 index 0000000..2427df2 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/explorer/ExplorerRow.java @@ -0,0 +1,9 @@ +package dev.propulsionteam.computed.client.editor.explorer; + +public record ExplorerRow( + String stablePath, + String label, + int depth, + boolean folder, + boolean expanded, + ExplorerNode node) {} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/explorer/NodeExplorerModel.java b/src/main/java/dev/propulsionteam/computed/client/editor/explorer/NodeExplorerModel.java new file mode 100644 index 0000000..4a9a2d6 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/explorer/NodeExplorerModel.java @@ -0,0 +1,179 @@ +package dev.propulsionteam.computed.client.editor.explorer; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class NodeExplorerModel { + private static final Comparator ENTRY_ORDER = Comparator + .comparing(Entry::isNode) + .thenComparing(Entry::label, String.CASE_INSENSITIVE_ORDER) + .thenComparing(Entry::stablePath); + + private final Entry root; + private final Set expanded = new HashSet<>(); + private Set expansionBeforeSearch = Set.of(); + private String query = ""; + private int selectedIndex; + + public NodeExplorerModel(List nodes) { + MutableEntry mutableRoot = new MutableEntry("", "", null); + if (nodes != null) { + nodes.forEach(node -> insert(mutableRoot, node)); + } + root = freeze(mutableRoot); + for (Entry owner : root.children()) { + expanded.add(owner.stablePath()); + } + } + + public void search(String query) { + String normalized = query == null ? "" : query.strip().toLowerCase(Locale.ROOT); + if (this.query.isEmpty() && !normalized.isEmpty()) { + expansionBeforeSearch = Set.copyOf(expanded); + } + if (!this.query.isEmpty() && normalized.isEmpty()) { + expanded.clear(); + expanded.addAll(expansionBeforeSearch); + expansionBeforeSearch = Set.of(); + } + this.query = normalized; + selectedIndex = 0; + } + + public List visibleRows() { + List rows = new ArrayList<>(); + for (Entry child : root.children()) { + append(child, 0, rows); + } + return List.copyOf(rows); + } + + public ExplorerRow selected() { + List rows = visibleRows(); + if (rows.isEmpty()) { + return null; + } + selectedIndex = Math.max(0, Math.min(selectedIndex, rows.size() - 1)); + return rows.get(selectedIndex); + } + + public void moveSelection(int delta) { + List rows = visibleRows(); + if (rows.isEmpty()) { + selectedIndex = 0; + } else { + selectedIndex = Math.floorMod(selectedIndex + delta, rows.size()); + } + } + + public void toggleSelected() { + ExplorerRow selected = selected(); + if (selected == null || !selected.folder()) { + return; + } + if (!expanded.remove(selected.stablePath())) { + expanded.add(selected.stablePath()); + } + } + + public void setExpanded(String stablePath, boolean value) { + if (value) { + expanded.add(stablePath); + } else { + expanded.remove(stablePath); + } + } + + public boolean isSearching() { + return !query.isEmpty(); + } + + private void append(Entry entry, int depth, List rows) { + Match match = match(entry); + if (!match.visible()) { + return; + } + boolean open = !query.isEmpty() ? match.descendantMatch() : expanded.contains(entry.stablePath()); + rows.add(new ExplorerRow( + entry.stablePath(), + entry.label(), + depth, + !entry.isNode(), + open, + entry.node())); + if (!entry.isNode() && open) { + entry.children().forEach(child -> append(child, depth + 1, rows)); + } + } + + private Match match(Entry entry) { + if (query.isEmpty()) { + return new Match(true, false); + } + boolean self = entry.label().toLowerCase(Locale.ROOT).contains(query) + || entry.stablePath().toLowerCase(Locale.ROOT).contains(query); + boolean descendant = entry.children().stream().anyMatch(child -> match(child).visible()); + return new Match(self || descendant, descendant); + } + + private static void insert(MutableEntry root, ExplorerNode node) { + String ownerId = node.ownership().name().toLowerCase(Locale.ROOT); + String ownerLabel = switch (node.ownership()) { + case BUNDLED -> "Bundled"; + case INTEGRATION -> "Integrations"; + case USER -> "User Nodes"; + }; + MutableEntry current = root.children.computeIfAbsent( + ownerId, + ignored -> new MutableEntry(ownerId, ownerLabel, null)); + String path = ownerId; + for (String segment : node.folderPath()) { + if (segment == null || segment.isBlank()) { + continue; + } + path += '/' + segment; + String stablePath = path; + current = current.children.computeIfAbsent( + segment, + ignored -> new MutableEntry(stablePath, segment, null)); + } + String nodePath = path + "/@" + node.id(); + current.children.put("@" + node.id(), new MutableEntry(nodePath, node.title(), node)); + } + + private static Entry freeze(MutableEntry entry) { + List children = entry.children.values().stream() + .map(NodeExplorerModel::freeze) + .sorted(ENTRY_ORDER) + .toList(); + return new Entry(entry.stablePath, entry.label, entry.node, children); + } + + private record Entry(String stablePath, String label, ExplorerNode node, List children) { + private boolean isNode() { + return node != null; + } + } + + private static final class MutableEntry { + private final String stablePath; + private final String label; + private final ExplorerNode node; + private final Map children = new LinkedHashMap<>(); + + private MutableEntry(String stablePath, String label, ExplorerNode node) { + this.stablePath = stablePath; + this.label = label; + this.node = node; + } + } + + private record Match(boolean visible, boolean descendantMatch) {} +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSession.java b/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSession.java new file mode 100644 index 0000000..06c3b42 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSession.java @@ -0,0 +1,66 @@ +package dev.propulsionteam.computed.client.editor.lua; + +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic; +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Phase; +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Severity; +import dev.propulsionteam.computed.lua.compiler.LuaCompilationException; +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.node.LuaDefinitionException; +import dev.propulsionteam.computed.lua.node.LuaDefinitionLoader; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.List; + +public final class LuaEditorSession { + public static final long DEBOUNCE_MILLIS = 250; + + private final LuaSourceCompiler compiler = new LuaSourceCompiler(); + private final LuaDefinitionLoader loader = new LuaDefinitionLoader(); + private String source = ""; + private long changedAt; + private boolean dirty; + private LuaNodeDefinition currentDefinition; + private LuaNodeDefinition lastValidDefinition; + private List diagnostics = List.of(); + + public void sourceChanged(String source, long nowMillis) { + this.source = source == null ? "" : source; + changedAt = nowMillis; + dirty = true; + currentDefinition = null; + } + + public boolean update(long nowMillis) { + if (!dirty || nowMillis - changedAt < DEBOUNCE_MILLIS) { + return false; + } + dirty = false; + try { + var compiled = compiler.compile(1, source); + currentDefinition = loader.load(compiled, new LuaSandbox()); + lastValidDefinition = currentDefinition; + diagnostics = List.of(); + } catch (LuaCompilationException exception) { + currentDefinition = null; + diagnostics = List.of(diagnostic(Phase.COMPILE, "compile_error", exception.getMessage())); + } catch (LuaDefinitionException exception) { + currentDefinition = null; + diagnostics = List.of(diagnostic(Phase.DEFINITION, "definition_error", exception.getMessage())); + } + return true; + } + + public LuaEditorSnapshot snapshot() { + return new LuaEditorSnapshot( + source, + currentDefinition, + lastValidDefinition, + dirty, + currentDefinition == null && lastValidDefinition != null, + diagnostics); + } + + private static ComputedDiagnostic diagnostic(Phase phase, String code, String message) { + return new ComputedDiagnostic(Severity.ERROR, phase, code, message, null, null, null); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSnapshot.java b/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSnapshot.java new file mode 100644 index 0000000..7d46cf4 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSnapshot.java @@ -0,0 +1,19 @@ +package dev.propulsionteam.computed.client.editor.lua; + +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import java.util.List; + +public record LuaEditorSnapshot( + String source, + LuaNodeDefinition currentDefinition, + LuaNodeDefinition lastValidDefinition, + boolean compiling, + boolean stalePreview, + List diagnostics) { + + public LuaEditorSnapshot { + source = source == null ? "" : source; + diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaNodeStarter.java b/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaNodeStarter.java new file mode 100644 index 0000000..fd03675 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaNodeStarter.java @@ -0,0 +1,34 @@ +package dev.propulsionteam.computed.client.editor.lua; + +import java.util.UUID; + +public final class LuaNodeStarter { + private LuaNodeStarter() {} + + public static Starter create() { + String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 12); + String id = "user:node_" + suffix; + String source = """ + local node = computed.node(1, "%s", "New Lua Node") + + node:category("lua") + node:input("value", "number", { default = 0 }) + node:field("factor", "number", { + default = 1, + label = "Factor", + control = "value", + step = 0.1 + }) + node:output("result", "number") + + node:on_run(function(ctx) + ctx:output("result", ctx:input("value") * ctx:field("factor")) + end) + + return node + """.formatted(id); + return new Starter(id, source); + } + + public record Starter(String id, String source) {} +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaSyntaxHighlighter.java b/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaSyntaxHighlighter.java new file mode 100644 index 0000000..daf869f --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/lua/LuaSyntaxHighlighter.java @@ -0,0 +1,308 @@ +package dev.propulsionteam.computed.client.editor.lua; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public final class LuaSyntaxHighlighter { + public static final int DEFAULT = 0xFFC5C5C5; + public static final int ATTRIBUTE = 0xFF7983AB; + public static final int SELF = 0xFFD1BE5F; + public static final int CONSTANT = 0xFF75A1C9; + public static final int STRING = 0xFF729369; + public static final int FUNCTION = 0xFF9073B6; + public static final int COMMENT = 0xFF5F5F5F; + public static final int LOCAL = 0xFFAC7070; + public static final int CONTROL = 0xFFCD844F; + + private static final Set CONTROL_WORDS = Set.of( + "and", + "break", + "do", + "else", + "elseif", + "end", + "for", + "function", + "goto", + "if", + "in", + "not", + "or", + "repeat", + "return", + "then", + "until", + "while"); + private static final Set CONSTANT_WORDS = Set.of("false", "nil", "true"); + + private LuaSyntaxHighlighter() {} + + public static List> highlight(String source) { + String[] lines = (source == null ? "" : source).split("\n", -1); + List> result = new ArrayList<>(lines.length); + State state = new State(); + for (String line : lines) { + result.add(highlightLine(line, state)); + } + return List.copyOf(result); + } + + private static List highlightLine(String line, State state) { + List spans = new ArrayList<>(); + int index = 0; + while (index < line.length()) { + if (state.longKind != LongKind.NONE) { + String closing = "]" + "=".repeat(state.longEquals) + "]"; + int end = line.indexOf(closing, index); + int color = state.longKind == LongKind.COMMENT ? COMMENT : STRING; + if (end < 0) { + add(spans, line.substring(index), color); + index = line.length(); + } else { + end += closing.length(); + add(spans, line.substring(index, end), color); + index = end; + state.longKind = LongKind.NONE; + } + continue; + } + + char character = line.charAt(index); + if (Character.isWhitespace(character)) { + int end = index + 1; + while (end < line.length() && Character.isWhitespace(line.charAt(end))) { + end++; + } + add(spans, line.substring(index, end), DEFAULT); + index = end; + continue; + } + + if (character == '-' && index + 1 < line.length() && line.charAt(index + 1) == '-') { + int opener = longBracketEquals(line, index + 2); + if (opener >= 0) { + String closing = "]" + "=".repeat(opener) + "]"; + int content = index + 4 + opener; + int end = line.indexOf(closing, content); + if (end < 0) { + add(spans, line.substring(index), COMMENT); + state.longKind = LongKind.COMMENT; + state.longEquals = opener; + break; + } + end += closing.length(); + add(spans, line.substring(index, end), COMMENT); + index = end; + continue; + } + add(spans, line.substring(index), COMMENT); + break; + } + + if (character == '"' || character == '\'') { + int end = quotedStringEnd(line, index, character); + add(spans, line.substring(index, end), STRING); + index = end; + continue; + } + + int longString = longBracketEquals(line, index); + if (longString >= 0) { + String closing = "]" + "=".repeat(longString) + "]"; + int content = index + 2 + longString; + int end = line.indexOf(closing, content); + if (end < 0) { + add(spans, line.substring(index), STRING); + state.longKind = LongKind.STRING; + state.longEquals = longString; + break; + } + end += closing.length(); + add(spans, line.substring(index, end), STRING); + index = end; + continue; + } + + if (isNumberStart(line, index)) { + int end = numberEnd(line, index); + add(spans, line.substring(index, end), CONSTANT); + index = end; + continue; + } + + if (isIdentifierStart(character)) { + int end = index + 1; + while (end < line.length() && isIdentifierPart(line.charAt(end))) { + end++; + } + String word = line.substring(index, end); + int next = nextNonWhitespace(line, end); + int color = word.equals("local") + ? LOCAL + : CONTROL_WORDS.contains(word) + ? CONTROL + : CONSTANT_WORDS.contains(word) + ? CONSTANT + : word.equals("self") + ? SELF + : next < line.length() && line.charAt(next) == '(' + ? FUNCTION + : state.tableDepth > 0 + && next < line.length() + && line.charAt(next) == '=' + ? ATTRIBUTE + : DEFAULT; + add(spans, word, color); + index = end; + continue; + } + + if (character == '{') { + state.tableDepth++; + } else if (character == '}') { + state.tableDepth = Math.max(0, state.tableDepth - 1); + } + add(spans, Character.toString(character), DEFAULT); + index++; + } + if (line.isEmpty()) { + return List.of(); + } + return List.copyOf(spans); + } + + private static void add(List spans, String text, int color) { + if (text.isEmpty()) { + return; + } + if (!spans.isEmpty() && spans.getLast().color() == color) { + Span previous = spans.removeLast(); + spans.add(new Span(previous.text() + text, color)); + } else { + spans.add(new Span(text, color)); + } + } + + private static int quotedStringEnd(String line, int start, char quote) { + boolean escaped = false; + for (int index = start + 1; index < line.length(); index++) { + char character = line.charAt(index); + if (escaped) { + escaped = false; + } else if (character == '\\') { + escaped = true; + } else if (character == quote) { + return index + 1; + } + } + return line.length(); + } + + private static int longBracketEquals(String line, int start) { + if (start >= line.length() || line.charAt(start) != '[') { + return -1; + } + int index = start + 1; + while (index < line.length() && line.charAt(index) == '=') { + index++; + } + return index < line.length() && line.charAt(index) == ']' ? index - start - 1 : -1; + } + + private static boolean isNumberStart(String line, int index) { + char character = line.charAt(index); + if (Character.isDigit(character)) { + return true; + } + return character == '.' + && index + 1 < line.length() + && Character.isDigit(line.charAt(index + 1)); + } + + private static int numberEnd(String line, int start) { + int index = start; + boolean hexadecimal = index + 1 < line.length() + && line.charAt(index) == '0' + && (line.charAt(index + 1) == 'x' || line.charAt(index + 1) == 'X'); + if (hexadecimal) { + index += 2; + while (index < line.length()) { + char character = line.charAt(index); + if (Character.digit(character, 16) < 0 + && character != '_' + && character != '.') { + break; + } + index++; + } + if (index < line.length() + && (line.charAt(index) == 'p' || line.charAt(index) == 'P')) { + index = exponentEnd(line, index + 1); + } + return index; + } + while (index < line.length() + && (Character.isDigit(line.charAt(index)) + || line.charAt(index) == '_')) { + index++; + } + if (index < line.length() && line.charAt(index) == '.') { + index++; + while (index < line.length() + && (Character.isDigit(line.charAt(index)) + || line.charAt(index) == '_')) { + index++; + } + } + if (index < line.length() + && (line.charAt(index) == 'e' || line.charAt(index) == 'E')) { + index = exponentEnd(line, index + 1); + } + return index; + } + + private static int exponentEnd(String line, int start) { + int index = start; + if (index < line.length() + && (line.charAt(index) == '+' || line.charAt(index) == '-')) { + index++; + } + while (index < line.length() + && (Character.isDigit(line.charAt(index)) + || line.charAt(index) == '_')) { + index++; + } + return index; + } + + private static int nextNonWhitespace(String line, int start) { + int index = start; + while (index < line.length() && Character.isWhitespace(line.charAt(index))) { + index++; + } + return index; + } + + private static boolean isIdentifierStart(char character) { + return character == '_' || Character.isLetter(character); + } + + private static boolean isIdentifierPart(char character) { + return character == '_' || Character.isLetterOrDigit(character); + } + + public record Span(String text, int color) {} + + private enum LongKind { + NONE, + COMMENT, + STRING + } + + private static final class State { + private LongKind longKind = LongKind.NONE; + private int longEquals; + private int tableDepth; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/editor/preview/LuaLivePreview.java b/src/main/java/dev/propulsionteam/computed/client/editor/preview/LuaLivePreview.java new file mode 100644 index 0000000..4fe3ad2 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/editor/preview/LuaLivePreview.java @@ -0,0 +1,74 @@ +package dev.propulsionteam.computed.client.editor.preview; + +import dev.propulsionteam.computed.client.renderer.node.NodeRenderLayout; +import dev.propulsionteam.computed.lua.runtime.LuaComputerRuntime; +import dev.propulsionteam.computed.lua.runtime.LuaInvocationResult; +import dev.propulsionteam.computed.lua.runtime.LuaNodeInstance; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.List; +import java.util.UUID; +import org.luaj.vm2.LuaValue; + +public final class LuaLivePreview { + private final UUID computerId = UUID.randomUUID(); + private final UUID nodeId = UUID.randomUUID(); + private final Map inputs = new LinkedHashMap<>(); + private final Map fields = new LinkedHashMap<>(); + private String source; + private LuaComputerRuntime runtime; + private LuaNodeInstance node; + private long tick; + + public LuaLivePreview(String source) { + reset(source); + } + + public void setInput(String id, LuaValue value) { + inputs.put(id, value); + } + + public void setField(String id, LuaValue value) { + fields.put(id, value); + } + + public LuaInvocationResult run() { + runtime.beginTick(++tick); + return node.run(inputs, fields, tick, runtime.nextGraphStep(), true, null); + } + + public LuaInvocationResult event(String eventName, LuaValue... arguments) { + runtime.beginTick(++tick); + return node.event( + eventName, + List.of(arguments), + inputs, + fields, + tick, + runtime.nextGraphStep(), + true, + null); + } + + public NodeRenderLayout layout() { + return NodeRenderLayout.measure(node.definition()); + } + + public void reset() { + reset(source); + } + + public void reset(String source) { + if (runtime != null) { + runtime.unload(); + } + this.source = source; + runtime = new LuaComputerRuntime(computerId); + node = runtime.createNode(nodeId, 1, source); + inputs.clear(); + node.definition().inputs().forEach(input -> inputs.put(input.id(), input.defaultValue())); + fields.clear(); + node.definition().fields().forEach(field -> fields.put(field.id(), field.defaultValue())); + tick = 0; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/renderer/node/BedrockNodeRenderer.java b/src/main/java/dev/propulsionteam/computed/client/renderer/node/BedrockNodeRenderer.java new file mode 100644 index 0000000..8dc0fba --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/renderer/node/BedrockNodeRenderer.java @@ -0,0 +1,132 @@ +package dev.propulsionteam.computed.client.renderer.node; + +import dev.propulsionteam.computed.internal.node.api.WNode; +import dev.propulsionteam.computed.internal.node.api.WPin; +import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; +import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; + +public final class BedrockNodeRenderer { + static final int CONTENT_INSET = 5; + static final int PIN_LABEL_PADDING = 5; + private static final int PIN_SIZE = 5; + private static final int PIN_HOVER_SIZE = 7; + + private BedrockNodeRenderer() {} + + public static void render( + GuiGraphics graphics, + WNode node, + String category, + boolean diagnosticError, + boolean diagnosticWarning, + int mouseX, + int mouseY) { + node.ensureLayoutUpToDate(); + int x = node.getX(); + int y = node.getY(); + int width = node.getWidth(); + int height = node.getHeight(); + boolean hovered = mouseX >= x + && mouseX <= x + width + && mouseY >= y + && mouseY <= y + height; + int frame = diagnosticError + ? NodePalette.ERROR + : diagnosticWarning + ? NodePalette.WARNING + : node.isSelected() + ? NodePalette.SELECTION + : NodePalette.category(category).frameArgb(); + graphics.fill(x, y, x + width, y + height, 0xFF090B0D); + graphics.fill(x + 1, y + 1, x + width - 1, y + height - 1, frame); + graphics.fill( + x + 2, + y + 2, + x + width - 2, + y + height - 2, + hovered ? 0xFF20252A : 0xFF171B1F); + graphics.fill(x + 3, y + 3, x + width - 3, y + 15, frame); + graphics.drawString( + Minecraft.getInstance().font, + node.getTitle(), + x + 6, + y + 4, + node.isSelected() && !diagnosticError && !diagnosticWarning + ? 0xFF101418 + : ComputedEditorTheme.TEXT_HEADER, + false); + for (int index = 0; index < node.getInputs().size(); index++) { + renderPin( + graphics, + node.getInputs().get(index), + x - 4, + y + 18 + index * 12, + true, + mouseX, + mouseY); + } + for (int index = 0; index < node.getOutputs().size(); index++) { + renderPin( + graphics, + node.getOutputs().get(index), + x + width - 1, + y + 18 + index * 12, + false, + mouseX, + mouseY); + } + } + + private static void renderPin( + GuiGraphics graphics, + WPin pin, + int x, + int y, + boolean input, + int mouseX, + int mouseY) { + boolean hovered = mouseX >= x - 1 + && mouseX <= x + PIN_SIZE + && mouseY >= y - 1 + && mouseY <= y + PIN_SIZE; + int size = hovered ? PIN_HOVER_SIZE : PIN_SIZE; + int left = x - (size - PIN_SIZE) / 2; + int top = y - (size - PIN_SIZE) / 2; + int color = pin.getColor(); + graphics.fill( + left, + top, + left + size, + top + size, + pin.isConnected() || hovered ? color : color & 0x66FFFFFF); + ComputedEditorStyle.drawPixelOutline( + graphics, + left, + top, + size, + size, + hovered ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.SOCKET_BORDER); + int centerLeft = left + Math.max(1, size / 2 - 1); + int centerTop = top + Math.max(1, size / 2 - 1); + graphics.fill( + centerLeft, + centerTop, + centerLeft + 2, + centerTop + 2, + ComputedEditorTheme.SOCKET_CENTER); + String label = pin.getName(); + int textX = input + ? x + 4 + CONTENT_INSET + PIN_LABEL_PADDING + : x - CONTENT_INSET - PIN_LABEL_PADDING + - Minecraft.getInstance().font.width(label); + graphics.drawString( + Minecraft.getInstance().font, + label, + textX, + y - 2, + ComputedEditorTheme.TEXT_SECONDARY, + false); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/renderer/node/NodePalette.java b/src/main/java/dev/propulsionteam/computed/client/renderer/node/NodePalette.java new file mode 100644 index 0000000..4b48b5a --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/renderer/node/NodePalette.java @@ -0,0 +1,46 @@ +package dev.propulsionteam.computed.client.renderer.node; + +import java.util.Locale; + +public enum NodePalette { + FLOW(0xFF27C7D9), + LOGIC(0xFF985AD6), + MATH(0xFF4E86E8), + WORLD(0xFF54A968), + STATE(0xFFE0A23D), + TEXT(0xFFD653B5), + WIDGETS(0xFF9BCB45), + IO(0xFFDA5252), + LUA(0xFF36A99A), + INTEGRATION(0xFFE1813B), + UTILITY(0xFF8A9099); + + public static final int SELECTION = 0xFFFFFFFF; + public static final int ERROR = 0xFFE65050; + public static final int WARNING = 0xFFF0B44C; + + private final int frameArgb; + + NodePalette(int frameArgb) { + this.frameArgb = frameArgb; + } + + public int frameArgb() { + return frameArgb; + } + + public static NodePalette category(String category) { + if (category == null) { + return UTILITY; + } + String normalized = category.toUpperCase(Locale.ROOT).replace('/', '_'); + if (normalized.equals("I_O")) { + normalized = "IO"; + } + try { + return valueOf(normalized); + } catch (RuntimeException exception) { + return normalized.startsWith("INTEGRATION") ? INTEGRATION : UTILITY; + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/client/renderer/node/NodeRenderLayout.java b/src/main/java/dev/propulsionteam/computed/client/renderer/node/NodeRenderLayout.java new file mode 100644 index 0000000..87547f4 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/client/renderer/node/NodeRenderLayout.java @@ -0,0 +1,63 @@ +package dev.propulsionteam.computed.client.renderer.node; + +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.node.LuaFieldSchema; +import dev.propulsionteam.computed.lua.node.NodeStyle; +import java.util.List; + +public record NodeRenderLayout( + int width, + int height, + int titleHeight, + int panelX, + int panelY, + int panelWidth, + int panelHeight, + int socketSpacing, + boolean sideRail) { + + public static NodeRenderLayout measure(LuaNodeDefinition definition) { + return measure(definition, definition.fields()); + } + + public static NodeRenderLayout measure( + LuaNodeDefinition definition, + List visibleFields) { + int portRows = Math.max(definition.inputs().size(), definition.outputs().size()); + int fieldRows = visibleFields.size(); + boolean compact = definition.style() == NodeStyle.COMPACT; + boolean sideRail = compact || definition.style() == NodeStyle.SINK; + int titleWidth = definition.title().length() * 6 + 20; + int inputWidth = definition.inputs().stream() + .mapToInt(port -> port.id().length() * 6) + .max() + .orElse(0); + int outputWidth = definition.outputs().stream() + .mapToInt(port -> port.id().length() * 6) + .max() + .orElse(0); + int portWidth = inputWidth + outputWidth + 38; + int fieldWidth = visibleFields.stream() + .mapToInt(field -> field.label().length() * 6 + 108) + .max() + .orElse(0); + int width = Math.max( + compact && fieldRows == 0 ? 96 : 144, + Math.max(titleWidth, Math.max(portWidth, fieldWidth))); + int titleHeight = 18; + int contentHeight = portRows * 12 + + (portRows > 0 && fieldRows > 0 ? 4 : 0) + + fieldRows * 18; + int panelHeight = Math.max(18, contentHeight + 8); + return new NodeRenderLayout( + width, + titleHeight + panelHeight + 4, + titleHeight, + 5, + titleHeight + 1, + width - 10, + panelHeight, + 12, + sideRail); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/content/ComputedGraphExecution.java b/src/main/java/dev/propulsionteam/computed/content/ComputedGraphExecution.java deleted file mode 100644 index cad0ba6..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/ComputedGraphExecution.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.propulsionteam.computed.content.blocks; - -import org.jetbrains.annotations.Nullable; - -/** Thread-local host for node evaluators running inside {@link ComputerBlockEntity}'s graph tick. */ -public final class ComputedGraphExecution { - private static final ThreadLocal HOST = new ThreadLocal<>(); - - private ComputedGraphExecution() {} - - public static void withHost(ComputerBlockEntity host, Runnable runnable) { - ComputerBlockEntity previous = HOST.get(); - HOST.set(host); - try { - runnable.run(); - } finally { - if (previous != null) { - HOST.set(previous); - } else { - HOST.remove(); - } - } - } - - /** Runs without inheriting a server host, used to make preview/validation evaluations side-effect free. */ - public static void withoutHost(Runnable runnable) { - ComputerBlockEntity previous = HOST.get(); - HOST.remove(); - try { - runnable.run(); - } finally { - if (previous != null) { - HOST.set(previous); - } - } - } - - @Nullable - public static ComputerBlockEntity hostOrNull() { - return HOST.get(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/ComputedMenuCategories.java b/src/main/java/dev/propulsionteam/computed/content/ComputedMenuCategories.java deleted file mode 100644 index 8785031..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/ComputedMenuCategories.java +++ /dev/null @@ -1,35 +0,0 @@ -package dev.propulsionteam.computed.content; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.Computed; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class ComputedMenuCategories { - public static final ResourceLocation VANILLA = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "menu_vanilla"); - public static final ResourceLocation CREATE = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "menu_create"); - public static final ResourceLocation CREATE_REDSTONE_LINK = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "menu_create_redstone_link"); - public static final ResourceLocation WIDGETS = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "menu_widgets"); - public static final ResourceLocation PERIPHERALS = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "menu_peripherals"); - public static final ResourceLocation CREATIVE = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "menu_creative"); - - private ComputedMenuCategories() {} - - public static void registerAll() { - NodeMenuRegistry.registerCategory(VANILLA, Component.literal("Vanilla"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(WIDGETS, Component.literal("Widgets"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(PERIPHERALS, Component.literal("Peripherals"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(CREATIVE, Component.literal("Creative"), NodeMenuRegistry.ROOT); - } - - public static void registerCreateCategories() { - NodeMenuRegistry.registerCategory(CREATE, Component.literal("Create"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(CREATE_REDSTONE_LINK, Component.literal("Redstone Link"), CREATE); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/ComputedNodes.java b/src/main/java/dev/propulsionteam/computed/content/ComputedNodes.java deleted file mode 100644 index d943776..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/ComputedNodes.java +++ /dev/null @@ -1,81 +0,0 @@ -package dev.propulsionteam.computed.content; - -import dev.propulsionteam.computed.content.nodes.create.CreateRedstoneLinkReceiverNode; -import dev.propulsionteam.computed.content.nodes.create.CreateRedstoneLinkSenderNode; -import dev.propulsionteam.computed.content.nodes.vanilla.BlockLocationNode; -import dev.propulsionteam.computed.content.nodes.vanilla.BlockPresenceNode; -import dev.propulsionteam.computed.content.nodes.vanilla.BlockRotationNode; -import dev.propulsionteam.computed.content.nodes.vanilla.ComparatorReadNode; -import dev.propulsionteam.computed.content.nodes.vanilla.ConcatenateTextNode; -import dev.propulsionteam.computed.content.nodes.vanilla.CommandNode; -import dev.propulsionteam.computed.content.nodes.vanilla.IfNode; -import dev.propulsionteam.computed.content.nodes.vanilla.RedstoneInputNode; -import dev.propulsionteam.computed.content.nodes.vanilla.RedstonePortNode; -import dev.propulsionteam.computed.content.nodes.vanilla.SwitchNode; -import dev.propulsionteam.computed.content.nodes.vanilla.WorldTimeNode; -import dev.propulsionteam.computed.content.nodes.widgets.ButtonWidgetNode; -import dev.propulsionteam.computed.content.nodes.widgets.ClockWidgetNode; -import dev.propulsionteam.computed.content.nodes.widgets.ColorSourceNode; -import dev.propulsionteam.computed.content.nodes.widgets.PeripheralNode; -import dev.propulsionteam.computed.content.nodes.widgets.ProgressBarWidgetNode; -import dev.propulsionteam.computed.content.nodes.widgets.SliderWidgetNode; -import dev.propulsionteam.computed.content.nodes.widgets.TextSourceNode; -import dev.propulsionteam.computed.content.nodes.widgets.TextWidgetNode; -import dev.propulsionteam.computed.customnodes.ComputedCustomNodes; -import dev.propulsionteam.computed.customnodes.expr.FunctionRegistry; -import dev.propulsionteam.computed.customnodes.sources.CreateSources; -import dev.propulsionteam.computed.customnodes.sources.WorldSources; - -public final class ComputedNodes { - - private ComputedNodes() {} - - public static void register() { - ComputedMenuCategories.registerAll(); - - // Register Java-backed source functions for data-driven nodes - WorldSources.register(FunctionRegistry.get()); - - // vanilla - RedstonePortNode.register(); - RedstoneInputNode.register(); - WorldTimeNode.register(); - ComparatorReadNode.register(); - BlockPresenceNode.register(); - BlockLocationNode.register(); - ConcatenateTextNode.register(); - BlockRotationNode.register(); - - // logic > comparison (under the built-in Logic category) - IfNode.register(); - SwitchNode.register(); - CommandNode.register(); - - // sources - TextSourceNode.register(); - ColorSourceNode.register(); - - // peripherals - PeripheralNode.register(); - - // widgets - TextWidgetNode.register(); - ClockWidgetNode.register(); - ButtonWidgetNode.register(); - SliderWidgetNode.register(); - ProgressBarWidgetNode.register(); - - // create — types always registered (for save compat); menu entries only when Create is loaded - if (net.neoforged.fml.ModList.get().isLoaded("create")) { - ComputedMenuCategories.registerCreateCategories(); - CreateRedstoneLinkSenderNode.register(); - CreateRedstoneLinkReceiverNode.register(); - CreateSources.register(FunctionRegistry.get()); - } else { - CreateRedstoneLinkSenderNode.registerType(); - CreateRedstoneLinkReceiverNode.registerType(); - } - - ComputedCustomNodes.reload(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockEntity.java b/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockEntity.java index 0b49598..5d4d717 100644 --- a/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockEntity.java +++ b/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockEntity.java @@ -1,32 +1,48 @@ package dev.propulsionteam.computed.content.blocks; -import dev.propulsionteam.computed.internal.node.api.FunctionCardNode; -import dev.propulsionteam.computed.internal.node.api.FunctionDefinitionStore; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.MissingNode; -import dev.propulsionteam.computed.internal.node.ProgramBridge; -import dev.propulsionteam.computed.node.program.ComputedProgram; -import dev.propulsionteam.computed.node.program.ProgramCodec; +import dev.propulsionteam.computed.Computed; import dev.propulsionteam.computed.content.ComputedRegistries; import dev.propulsionteam.computed.content.Peripherals; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.nodes.vanilla.RedstonePortNode; -import dev.propulsionteam.computed.integration.CreateRedstoneLinkBridge; +import dev.propulsionteam.computed.content.monitors.MonitorBlockEntity; +import dev.propulsionteam.computed.content.monitors.widgets.ButtonWidget; +import dev.propulsionteam.computed.content.monitors.widgets.ClockWidget; +import dev.propulsionteam.computed.content.monitors.widgets.LayoutManagedWidget; +import dev.propulsionteam.computed.content.monitors.widgets.MonitorWidgetLayout; +import dev.propulsionteam.computed.content.monitors.widgets.ProgressBarWidget; +import dev.propulsionteam.computed.content.monitors.widgets.SliderWidget; +import dev.propulsionteam.computed.content.monitors.widgets.TextAlignment; +import dev.propulsionteam.computed.content.monitors.widgets.TextWidget; +import dev.propulsionteam.computed.content.monitors.widgets.Widget; +import dev.propulsionteam.computed.content.monitors.widgets.WidgetDrawList; +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.GraphNode; +import dev.propulsionteam.computed.graph.LuaGraphScheduler; +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpointHost; +import dev.propulsionteam.computed.lua.endpoint.BuiltinWidget; import dev.propulsionteam.computed.menu.ComputerPeripheralMenu; -import net.minecraft.core.registries.BuiltInRegistries; +import dev.propulsionteam.computed.network.ComputerEditPolicy; +import dev.propulsionteam.computed.network.ComputedNetworking; +import dev.propulsionteam.computed.persistence.ProgramV3Codec; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.commands.CommandSourceStack; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.core.HolderLookup; import net.minecraft.core.NonNullList; import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtIo; import net.minecraft.nbt.Tag; import net.minecraft.network.chat.Component; import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; import net.minecraft.world.ContainerHelper; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.AbstractContainerMenu; @@ -34,139 +50,43 @@ import net.minecraft.world.level.Level; import net.minecraft.world.level.block.entity.BaseContainerBlockEntity; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.UUID; -import java.io.ByteArrayOutputStream; -import java.io.DataOutputStream; -import java.io.IOException; -import net.minecraft.nbt.NbtIo; - -public class ComputerBlockEntity extends BaseContainerBlockEntity { +public class ComputerBlockEntity extends BaseContainerBlockEntity implements BuiltinEndpointHost { public static final int CONTAINER_SIZE = 9; - private static final int MAX_PROGRAM_NODES = 4096; - private static final int MAX_PROGRAM_CONNECTIONS = 20_000; - private static final int MAX_PROGRAM_FUNCTIONS = 256; - private static final int MAX_NESTED_GRAPH_DEPTH = 16; - private static final int MAX_PROGRAM_BYTES = 4 * 1024 * 1024; + public static final String PROGRAM_TAG = "ComputedProgram"; private final NonNullList items = NonNullList.withSize(CONTAINER_SIZE, ItemStack.EMPTY); - private WGraph graph = new WGraph(); - /** Saved function bodies keyed by id (parallel to graph function cards). */ - private FunctionDefinitionStore functionDefinitions = new FunctionDefinitionStore(); - /** Weak redstone emitted toward each {@link Direction} (neighbor on that side sees this level). */ - private final int[] redstoneEmitted = new int[6]; - private final CreateRedstoneLinkBridge createRedstoneLinks = new CreateRedstoneLinkBridge(); + private ComputedProgramV3 program; + private LuaGraphScheduler scheduler; + private CompoundTag unreadableProgramData; private UUID computerUuid; private long programRevision; - /** Canonical v2 source retained so unsupported addon data survives the transitional runtime. */ - private ComputedProgram persistedProgram; - /** - * Program fields that could not be decoded (for example, a newer format version). They are - * written back verbatim until an explicitly validated editor save replaces them. - */ - private CompoundTag unreadableProgramData; private transient boolean dropsHandled; + private final int[] emittedRedstone = new int[Direction.values().length]; public ComputerBlockEntity(BlockPos pos, BlockState state) { super(ComputedRegistries.COMPUTER_BLOCK_ENTITY.get(), pos, state); + program = ComputedProgramV3.empty(stableGraphId(pos)); } - /** - * Runs the node graph on the server world thread at 20 TPS. Evaluators are not safe for arbitrary - * background threads without a numeric snapshot pipeline, so stepping stays synchronous here. - */ - public static void tick(Level level, BlockPos pos, BlockState state, ComputerBlockEntity be) { - if (level.isClientSide) { - return; - } - // Sable's sub-level tick dispatcher doesn't drop removed BEs from its ticker list the way - // vanilla chunks do, so the graph would keep executing after the computer is broken. - if (be.isRemoved()) { + public static void tick(Level level, BlockPos pos, BlockState state, ComputerBlockEntity computer) { + if (level.isClientSide || computer.isRemoved()) { return; } - Level lvl = be.getLevel(); - if (CreateRedstoneLinkBridge.isCreateLoaded() && lvl != null && !lvl.isClientSide) { - be.createRedstoneLinks.ensureSynced(lvl, be, be.graph); + LuaGraphScheduler active = computer.ensureScheduler(); + ComputedProgramV3 before = computer.program; + active.tick(false); + ComputedProgramV3 after = active.snapshot(computer.programRevision); + computer.program = after; + if (!before.persistentState().equals(after.persistentState())) { + computer.setChanged(); } - ComputedGraphExecution.withHost(be, () -> be.graph.advanceSimulationInWorld(1.0 / WGraph.MAX_TICK_RATE)); - if (CreateRedstoneLinkBridge.isCreateLoaded() && lvl != null && !lvl.isClientSide) { - be.createRedstoneLinks.pushTransmitters(lvl); - } - be.mutePeripheralsWithoutHardware(be.graph); - be.refreshRedstoneFromGraph(); } - /** - * Returns the weak signal emitted from the given face of the computer. Minecraft's - * {@code getSignal(..., direction)} passes {@code direction} as the direction from the querying - * neighbor toward this block, so the face being queried is its opposite. - */ public int getEmittedRedstone(Direction fromNeighborTowardSelf) { - return redstoneEmitted[fromNeighborTowardSelf.getOpposite().ordinal()]; - } - - /** Zeros outputs for peripheral nodes with no matching item in this computer (including nested function graphs). */ - private void mutePeripheralsWithoutHardware(WGraph g) { - for (WNode n : g.getNodes()) { - if (n instanceof FunctionCardNode fc) { - mutePeripheralsWithoutHardware(fc.getInnerGraph()); - } - if (Peripherals.isPeripheralNodeType(n.getTypeId()) && !hasPeripheralEquipped(n.getTypeId())) { - for (var out : n.getOutputs()) { - out.setValue(0.0); - } - } - } - } - - private void refreshRedstoneFromGraph() { - Level lvl = this.level; - if (lvl == null || lvl.isClientSide) { - return; - } - int[] next = new int[6]; - List ports = new ArrayList<>(); - collectRedstonePorts(graph, ports); - BlockState st = getBlockState(); - Direction facing = st.getValue(ComputerBlock.FACING); - for (RedstonePortNode rp : ports) { - if (!hasPeripheralEquipped(RedstonePortNode.TYPE_ID)) { - continue; - } - WNode n = rp; - if (n.getInputs().size() < 2) { - continue; - } - double tick = n.getInputs().get(0).getValue(); - double lv = n.getInputs().get(1).getValue(); - if (tick > 0.5) { - int p = net.minecraft.util.Mth.clamp((int) Math.round(lv), 0, 15); - int o = rp.getEmitFace().toWorld(facing).ordinal(); - next[o] = Math.max(next[o], p); - } - } - if (!Arrays.equals(next, redstoneEmitted)) { - System.arraycopy(next, 0, redstoneEmitted, 0, 6); - setChanged(); - lvl.updateNeighborsAt(worldPosition, st.getBlock()); - for (Direction d : Direction.values()) { - lvl.neighborChanged(worldPosition.relative(d), st.getBlock(), worldPosition); - } - } - } - - private static void collectRedstonePorts(WGraph g, List out) { - for (WNode n : g.getNodes()) { - if (n instanceof RedstonePortNode rp) { - out.add(rp); - } else if (n instanceof FunctionCardNode fc) { - collectRedstonePorts(fc.getInnerGraph(), out); - } - } + return emittedRedstone[fromNeighborTowardSelf.getOpposite().ordinal()]; } @Override @@ -187,33 +107,42 @@ protected NonNullList getItems() { @Override protected void setItems(NonNullList newItems) { items.clear(); - for (int i = 0; i < Math.min(newItems.size(), items.size()); i++) { - items.set(i, newItems.get(i)); + for (int index = 0; index < Math.min(newItems.size(), items.size()); index++) { + items.set(index, newItems.get(index)); } } @Override protected AbstractContainerMenu createMenu(int containerId, Inventory playerInventory) { - return new ComputerPeripheralMenu(ComputedRegistries.COMPUTER_PERIPHERAL_MENU.get(), containerId, playerInventory, this); + return new ComputerPeripheralMenu( + ComputedRegistries.COMPUTER_PERIPHERAL_MENU.get(), + containerId, + playerInventory, + this); } - @Override - public void setItem(int slot, ItemStack stack) { - super.setItem(slot, stack); - } - - public WGraph getGraph() { - return graph; + public CompoundTag getGraphData() { + CompoundTag envelope = new CompoundTag(); + envelope.put(PROGRAM_TAG, ProgramV3Codec.encode(snapshotProgram())); + Peripherals.writePeripheralUnlockTag(this, envelope); + return envelope; } - public CompoundTag getGraphData() { - return ProgramBridge.writeEnvelope(snapshotProgram()); + public ComputedProgramV3 getProgram() { + return snapshotProgram(); } public long getProgramRevision() { return programRevision; } + public boolean handleWidgetInput(UUID nodeId, double value) { + return ensureScheduler().eventNode( + nodeId, + "input", + org.luaj.vm2.LuaValue.valueOf(value)); + } + public record ApplyGraphResult(boolean accepted, long serverRevision, String message) { static ApplyGraphResult accepted(long revision) { return new ApplyGraphResult(true, revision, "ok"); @@ -224,118 +153,88 @@ static ApplyGraphResult rejected(long revision, String message) { } } - /** Validates into temporary objects and swaps them only when the complete program is valid. */ public ApplyGraphResult applyGraphFromNetwork(CompoundTag tag, long expectedRevision) { - if (expectedRevision != programRevision) { - return ApplyGraphResult.rejected( - programRevision, - "stale editor revision (expected " + programRevision + ", received " + expectedRevision + ")"); + String revisionError = ComputerEditPolicy.revision(programRevision, expectedRevision); + if (revisionError != null) { + return ApplyGraphResult.rejected(programRevision, revisionError); } String sizeError = validateEncodedSize(tag); if (sizeError != null) { return ApplyGraphResult.rejected(programRevision, sizeError); } - CompoundTag copy = tag.copy(); - Peripherals.stripEditorOnlyTags(copy); - java.util.Map inputSnap = new java.util.HashMap<>(); - java.util.Map outputSnap = new java.util.HashMap<>(); - snapshotPinValues(graph, inputSnap, outputSnap); - ComputedProgram liveProgram = snapshotProgram(); - ProgramBridge.RuntimeProgram decoded; + ComputedProgramV3 incoming; try { - decoded = ProgramBridge.decode(copy); + ProgramV3Codec.LoadResult decoded = + ProgramV3Codec.decode(tag, worldPosition.toShortString(), ignored -> {}); + if (decoded.discardedLegacy()) { + return ApplyGraphResult.rejected(programRevision, "legacy graph and clipboard formats are not accepted"); + } + incoming = decoded.program(); } catch (RuntimeException exception) { - return ApplyGraphResult.rejected(programRevision, "program could not be decoded: " + exception.getMessage()); + return ApplyGraphResult.rejected( + programRevision, + "program could not be decoded: " + exception.getMessage()); } - String validationError = validateProgram(decoded.program()); + String validationError = validateProgram(incoming); if (validationError != null) { return ApplyGraphResult.rejected(programRevision, validationError); } - ComputedProgram stateMerged = ProgramBridge.preserveRuntimeState(decoded.program(), liveProgram); - ProgramBridge.RuntimeProgram stateDecoded; + ComputedProgramV3 stateMerged = preserveRuntimeState(incoming, snapshotProgram()); + LuaGraphScheduler nextScheduler; try { - stateDecoded = ProgramBridge.decode(ProgramBridge.writeEnvelope(stateMerged)); + nextScheduler = new LuaGraphScheduler(stateMerged, getOrCreateUuid(), this); + var error = nextScheduler.validationDiagnostics().stream() + .filter(diagnostic -> diagnostic.severity() + == dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Severity.ERROR) + .findFirst(); + if (error.isPresent()) { + nextScheduler.unload(); + return ApplyGraphResult.rejected( + programRevision, + "program validation failed: " + error.get().message()); + } } catch (RuntimeException exception) { return ApplyGraphResult.rejected( - programRevision, "program could not preserve authoritative runtime state: " + exception.getMessage()); + programRevision, + "program validation failed: " + exception.getMessage()); + } + if (scheduler != null) { + scheduler.unload(); } - WGraph nextGraph = stateDecoded.graph(); - FunctionDefinitionStore nextFunctions = stateDecoded.functions(); - restorePinValues(nextGraph, inputSnap, outputSnap); - graph = nextGraph; - functionDefinitions = nextFunctions; programRevision++; - persistedProgram = stateDecoded.program().withRevision(programRevision); + program = stateMerged.withRevision(programRevision); + scheduler = nextScheduler; unreadableProgramData = null; - createRedstoneLinks.markGraphDirty(); setChanged(); return ApplyGraphResult.accepted(programRevision); } - private String validateProgram(ComputedProgram program) { - if (program.functions().size() > MAX_PROGRAM_FUNCTIONS) { - return "program exceeds the function limit of " + MAX_PROGRAM_FUNCTIONS; - } - long modelNodes = program.rootGraph().nodes().size(); - long modelConnections = program.rootGraph().connections().size(); - for (var function : program.functions()) { - modelNodes += function.graph().nodes().size(); - modelConnections += function.graph().connections().size(); - } - if (modelNodes > MAX_PROGRAM_NODES) { - return "program exceeds the node limit of " + MAX_PROGRAM_NODES; - } - if (modelConnections > MAX_PROGRAM_CONNECTIONS) { - return "program exceeds the connection limit of " + MAX_PROGRAM_CONNECTIONS; - } - CompoundTag legacyBundle = ProgramCodec.toLegacyBundleTag(program); - String structuralError = validateProgramTag(legacyBundle); - if (structuralError != null) return structuralError; - - var incomingAnalyses = ProgramBridge.analyzeAll(program); - var previousAnalyses = persistedProgram == null ? List.of() : ProgramBridge.analyzeAll(persistedProgram); - java.util.Set existingCycles = new java.util.HashSet<>(); - for (var analyzedGraph : previousAnalyses) { - for (List cycle : analyzedGraph.analysis().combinationalCycles()) { - existingCycles.add(new GraphCycleKey(analyzedGraph.graphId(), java.util.Set.copyOf(cycle))); + private String validateProgram(ComputedProgramV3 candidate) { + return ComputerEditPolicy.programShape(candidate); + } + + private static ComputedProgramV3 preserveRuntimeState( + ComputedProgramV3 incoming, + ComputedProgramV3 authoritative) { + Map currentNodes = new LinkedHashMap<>(); + authoritative.rootGraph().nodes().forEach(node -> currentNodes.put(node.id(), node)); + Map merged = new LinkedHashMap<>(incoming.persistentState()); + incoming.rootGraph().nodes().forEach(node -> { + GraphNode current = currentNodes.get(node.id()); + CompoundTag state = authoritative.persistentState().get(node.id()); + if (current != null + && state != null + && current.definitionId().equals(node.definitionId()) + && current.definitionHash().equals(node.definitionHash())) { + merged.put(node.id(), state); } - } - for (var analyzedGraph : incomingAnalyses) { - for (List cycle : analyzedGraph.analysis().combinationalCycles()) { - if (!existingCycles.contains(new GraphCycleKey(analyzedGraph.graphId(), java.util.Set.copyOf(cycle)))) { - return "program introduces a new combinational cycle in graph " + analyzedGraph.graphId(); - } - } - } - java.util.Set existingStructuralDiagnostics = new java.util.HashSet<>(); - for (var analyzedGraph : previousAnalyses) { - for (var diagnostic : analyzedGraph.analysis().diagnostics()) { - existingStructuralDiagnostics.add(structuralDiagnosticKey(diagnostic)); - } - } - for (var analyzedGraph : incomingAnalyses) { - for (var diagnostic : analyzedGraph.analysis().diagnostics()) { - if (diagnostic.severity() - != dev.propulsionteam.computed.node.program.ProgramDiagnostic.Severity.ERROR - || "placeholder_node_disabled".equals(diagnostic.code()) - || "combinational_cycle".equals(diagnostic.code()) - || "combinational_self_loop".equals(diagnostic.code())) { - continue; - } - if (!existingStructuralDiagnostics.contains(structuralDiagnosticKey(diagnostic))) { - return "program validation failed in graph " + analyzedGraph.graphId() + ": " + diagnostic.message(); - } - } - } - return null; - } - - private record GraphCycleKey(UUID graphId, java.util.Set nodeIds) {} - - private static String structuralDiagnosticKey( - dev.propulsionteam.computed.node.program.ProgramDiagnostic diagnostic) { - return diagnostic.code() + "|" + diagnostic.graphId() + "|" + diagnostic.nodeId() + "|" - + diagnostic.connectionId(); + }); + return new ComputedProgramV3( + incoming.revision(), + incoming.rootGraph(), + incoming.library(), + merged, + incoming.metadata()); } private static String validateEncodedSize(CompoundTag tag) { @@ -344,152 +243,20 @@ private static String validateEncodedSize(CompoundTag tag) { try (DataOutputStream output = new DataOutputStream(bytes)) { NbtIo.write(tag, output); } - return bytes.size() > MAX_PROGRAM_BYTES - ? "program exceeds the encoded size limit of " + MAX_PROGRAM_BYTES + " bytes" - : null; + return ComputerEditPolicy.encodedSize(bytes.size()); } catch (IOException | RuntimeException exception) { - return "program NBT could not be measured safely"; - } - } - - private static String validateProgramTag(CompoundTag bundle) { - CompoundTag graphTag = bundle.contains("ComputerGraph", Tag.TAG_COMPOUND) - ? bundle.getCompound("ComputerGraph") - : bundle; - int[] totals = new int[2]; - String graphError = validateGraphTag(graphTag, 0, totals); - if (graphError != null) { - return graphError; - } - ListTag functions = bundle.getList("ComputerFunctions", Tag.TAG_COMPOUND); - if (functions.size() > MAX_PROGRAM_FUNCTIONS) { - return "program exceeds the function limit of " + MAX_PROGRAM_FUNCTIONS; - } - for (int i = 0; i < functions.size(); i++) { - String error = validateGraphTag(functions.getCompound(i).getCompound("Body"), 1, totals); - if (error != null) { - return "function " + i + ": " + error; - } - } - return null; - } - - private static String validateGraphTag(CompoundTag graphTag, int depth, int[] totals) { - if (depth > MAX_NESTED_GRAPH_DEPTH) { - return "nested functions exceed depth " + MAX_NESTED_GRAPH_DEPTH; - } - ListTag nodes = graphTag.getList("nodes", Tag.TAG_COMPOUND); - ListTag connections = graphTag.getList("conns", Tag.TAG_COMPOUND); - totals[0] += nodes.size(); - totals[1] += connections.size(); - if (totals[0] > MAX_PROGRAM_NODES) { - return "program exceeds the node limit of " + MAX_PROGRAM_NODES; - } - if (totals[1] > MAX_PROGRAM_CONNECTIONS) { - return "program exceeds the connection limit of " + MAX_PROGRAM_CONNECTIONS; - } - for (int i = 0; i < nodes.size(); i++) { - CompoundTag node = nodes.getCompound(i); - try { - ResourceLocation type = ResourceLocation.parse(node.getString("typeId")); - if (!NodeRegistry.isRegistered(type) && !node.getBoolean(MissingNode.MISSING_MARKER)) { - return "unknown node type " + type; - } - } catch (RuntimeException exception) { - return "node " + i + " has an invalid type ID"; - } - if (node.contains("inner", Tag.TAG_COMPOUND)) { - String error = validateGraphTag(node.getCompound("inner"), depth + 1, totals); - if (error != null) { - return error; - } - } - } - return null; - } - - private static void snapshotPinValues(WGraph g, - java.util.Map ins, - java.util.Map outs) { - for (WNode n : g.getNodes()) { - PinSnapshot[] in = new PinSnapshot[n.getInputs().size()]; - for (int i = 0; i < in.length; i++) in[i] = PinSnapshot.capture(n.getInputs().get(i)); - PinSnapshot[] out = new PinSnapshot[n.getOutputs().size()]; - for (int i = 0; i < out.length; i++) out[i] = PinSnapshot.capture(n.getOutputs().get(i)); - ins.put(n.getId(), in); - outs.put(n.getId(), out); - if (n instanceof FunctionCardNode fc) { - snapshotPinValues(fc.getInnerGraph(), ins, outs); - } - } - } - - private static void restorePinValues(WGraph g, - java.util.Map ins, - java.util.Map outs) { - for (WNode n : g.getNodes()) { - PinSnapshot[] in = ins.get(n.getId()); - if (in != null) { - restorePins(n.getInputs(), in); - } - PinSnapshot[] out = outs.get(n.getId()); - if (out != null) { - restorePins(n.getOutputs(), out); - } - if (n instanceof FunctionCardNode fc) { - restorePinValues(fc.getInnerGraph(), ins, outs); - } - } - } - - private static void restorePins(List pins, PinSnapshot[] snapshots) { - java.util.Map byStableKey = new java.util.HashMap<>(); - for (PinSnapshot snapshot : snapshots) { - if (snapshot.stableKey() != null) byStableKey.putIfAbsent(snapshot.stableKey(), snapshot); - } - for (int i = 0; i < pins.size(); i++) { - WPin pin = pins.get(i); - PinSnapshot snapshot = pin.getStableKey() == null - ? (i < snapshots.length ? snapshots[i] : null) - : byStableKey.get(pin.getStableKey()); - if (snapshot != null) snapshot.restoreTo(pin); + return ComputerEditPolicy.encodedSize(-1); } } - private record PinSnapshot( - String stableKey, WPin.DataType type, double numberValue, String stringValue, Object widgetValue) { - static PinSnapshot capture(WPin pin) { - return new PinSnapshot( - pin.getStableKey(), pin.getDataType(), pin.getValue(), pin.getStringValue(), pin.getWidgetValue()); - } - - void restoreTo(WPin pin) { - if (pin.getDataType() != type) { - return; - } - switch (type) { - case NUMBER -> pin.setValue(numberValue); - case STRING -> pin.setStringValue(stringValue); - case WIDGET -> pin.setWidgetValue(widgetValue); - } - } - } - - private void hydrateFunctionCardsFromLibrary() { - FunctionCardNode.applyLibraryToInnerGraphs(graph, functionDefinitions); - } - - /** - * Shift-use: place one peripheral into the first valid empty slot (unique types only). - */ public boolean tryInsertPeripheralFromHand(ItemStack stack) { if (!Peripherals.isPeripheral(stack)) { return false; } ItemStack one = stack.split(1); - for (int i = 0; i < CONTAINER_SIZE; i++) { - if (getItem(i).isEmpty() && Peripherals.mayPlaceInComputer(this, i, one)) { - setItem(i, one); + for (int index = 0; index < CONTAINER_SIZE; index++) { + if (getItem(index).isEmpty() && Peripherals.mayPlaceInComputer(this, index, one)) { + setItem(index, one); return true; } } @@ -497,8 +264,7 @@ public boolean tryInsertPeripheralFromHand(ItemStack stack) { return false; } - /** Always returns true: there are no hardware-gated nodes after the peripheral simplification. */ - public boolean hasPeripheralEquipped(ResourceLocation nodeTypeId) { + public boolean hasPeripheralEquipped(net.minecraft.resources.ResourceLocation nodeTypeId) { return true; } @@ -508,7 +274,6 @@ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) ContainerHelper.loadAllItems(tag, items, registries); computerUuid = tag.hasUUID("ComputerUUID") ? tag.getUUID("ComputerUUID") : null; loadProgramData(tag); - createRedstoneLinks.markGraphDirty(); } @Override @@ -530,13 +295,13 @@ public UUID getOrCreateUuid() { } public boolean hasStoredState() { - if (unreadableProgramData != null && !unreadableProgramData.isEmpty()) return true; - if (!graph.getNodes().isEmpty()) return true; - if (!functionDefinitions.isEmpty()) return true; - for (ItemStack s : items) { - if (!s.isEmpty()) return true; + if (unreadableProgramData != null && !unreadableProgramData.isEmpty()) { + return true; } - return false; + if (!program.rootGraph().nodes().isEmpty() || !program.library().isEmpty()) { + return true; + } + return items.stream().anyMatch(stack -> !stack.isEmpty()); } public void markDropsHandled() { @@ -549,9 +314,9 @@ public boolean dropsHandled() { @Override public void setRemoved() { - Level lvl = this.level; - if (lvl != null && !lvl.isClientSide) { - createRedstoneLinks.clear(lvl); + if (scheduler != null) { + scheduler.unload(); + scheduler = null; } super.setRemoved(); } @@ -569,90 +334,337 @@ public void handleUpdateTag(CompoundTag tag, HolderLookup.Provider registries) { loadProgramData(tag); } - private ComputedProgram snapshotProgram() { - ComputedProgram snapshot = ProgramBridge.snapshot(graph, functionDefinitions, programRevision); - persistedProgram = ProgramBridge.reconcile(persistedProgram, snapshot).withRevision(programRevision); - return persistedProgram; + @Override + public double worldTime() { + return level == null ? 0 : level.getDayTime(); + } + + @Override + public double[] position() { + Vec3 position = Vec3.atCenterOf(worldPosition); + return new double[] {position.x, position.y, position.z}; + } + + @Override + public double[] rotation() { + Direction facing = getBlockState().hasProperty(ComputerBlock.FACING) + ? getBlockState().getValue(ComputerBlock.FACING) + : Direction.NORTH; + return new double[] {facing.toYRot(), 0, 0}; + } + + @Override + public int redstoneInput(String face) { + Direction worldFace = worldFace(face); + if (worldFace == null || level == null || level.isClientSide) { + return 0; + } + BlockPos neighbor = worldPosition.relative(worldFace); + return level.getSignal(neighbor, worldFace); + } + + @Override + public int comparatorInput(String face) { + Direction worldFace = worldFace(face); + if (worldFace == null || level == null || level.isClientSide) { + return 0; + } + BlockPos neighbor = worldPosition.relative(worldFace); + BlockState target = level.getBlockState(neighbor); + return target.hasAnalogOutputSignal() + ? target.getAnalogOutputSignal(level, neighbor) + : level.getSignal(neighbor, worldFace); + } + + @Override + public boolean blockPresent(String face) { + Direction worldFace = worldFace(face); + return worldFace != null + && level != null + && !level.isClientSide + && !level.getBlockState(worldPosition.relative(worldFace)).isAir(); + } + + @Override + public void redstoneOutput(String face, int power) { + Direction worldFace = worldFace(face); + if (worldFace == null || level == null || level.isClientSide) { + return; + } + int clamped = net.minecraft.util.Mth.clamp(power, 0, 15); + if (emittedRedstone[worldFace.ordinal()] == clamped) { + return; + } + emittedRedstone[worldFace.ordinal()] = clamped; + level.updateNeighborsAt(worldPosition, getBlockState().getBlock()); + } + + @Override + public void showWidgets(String target, List definitions) { + if (level == null || level.isClientSide) { + return; + } + MinecraftServer server = level.getServer(); + if (server != null && !server.isSameThread()) { + String queuedTarget = target; + List queuedDefinitions = List.copyOf(definitions); + server.execute(() -> applyWidgets(queuedTarget, queuedDefinitions)); + return; + } + applyWidgets(target, definitions); + } + + private void applyWidgets(String target, List definitions) { + Direction direction = worldFace(target); + if (direction == null || level == null || level.isClientSide) { + return; + } + BlockPos targetPos = worldPosition.relative(direction); + var targetEntity = level.getBlockEntity(targetPos); + if (!(targetEntity instanceof MonitorBlockEntity monitor)) { + return; + } + MonitorBlockEntity origin = monitor.findOrigin(); + if (origin == null) { + return; + } + List widgets = definitions.stream() + .map(ComputerBlockEntity::toWidget) + .filter(java.util.Objects::nonNull) + .toList(); + int screenWidth = origin.getWidth() * ComputedNetworking.SCREEN_PX_PER_BLOCK; + int screenHeight = origin.getHeight() * ComputedNetworking.SCREEN_PX_PER_BLOCK; + widgets = MonitorWidgetLayout.resolve(widgets, screenWidth, screenHeight); + origin.bindOwner(worldPosition); + origin.setDrawList(new WidgetDrawList(widgets)); + } + + @Override + public void runCommand(String commandText) { + if (commandText == null + || commandText.isBlank() + || !(level instanceof ServerLevel serverLevel)) { + return; + } + MinecraftServer server = serverLevel.getServer(); + String command = commandText.startsWith("/") ? commandText.substring(1) : commandText; + if (server == null || command.isBlank()) { + return; + } + Vec3 center = Vec3.atCenterOf(worldPosition); + CommandSourceStack source = server.createCommandSourceStack() + .withLevel(serverLevel) + .withPosition(center) + .withPermission(4) + .withSuppressedOutput(); + server.getCommands().performPrefixedCommand(source, command); + } + + private LuaGraphScheduler ensureScheduler() { + if (scheduler == null) { + scheduler = new LuaGraphScheduler(program, getOrCreateUuid(), this); + } + return scheduler; + } + + private ComputedProgramV3 snapshotProgram() { + if (scheduler != null) { + program = scheduler.snapshot(programRevision); + } + return program.withRevision(programRevision); } private void writeStoredProgram(CompoundTag target) { if (unreadableProgramData != null && !unreadableProgramData.isEmpty()) { for (String key : unreadableProgramData.getAllKeys()) { Tag value = unreadableProgramData.get(key); - if (value != null) target.put(key, value.copy()); + if (value != null) { + target.put(key, value.copy()); + } } return; } - target.put(ProgramBridge.PROGRAM_TAG, ProgramCodec.write(snapshotProgram())); + target.put(PROGRAM_TAG, ProgramV3Codec.encode(snapshotProgram())); } - private void loadProgramData(CompoundTag tag) { - if (!ProgramBridge.containsProgram(tag)) { - graph = new WGraph(); - functionDefinitions = new FunctionDefinitionStore(); - persistedProgram = null; + private void loadProgramData(CompoundTag source) { + if (scheduler != null) { + scheduler.unload(); + scheduler = null; + } + if (!containsProgramData(source)) { + program = ComputedProgramV3.empty(stableGraphId(worldPosition)); + programRevision = 0; unreadableProgramData = null; - programRevision = 0L; return; } try { - ProgramBridge.RuntimeProgram decoded = ProgramBridge.decode(tag); - graph = decoded.graph(); - functionDefinitions = decoded.functions(); - long legacyRevision = tag.contains("ComputedProgramRevision") - ? Math.max(0L, tag.getLong("ComputedProgramRevision")) - : 0L; - programRevision = Math.max(decoded.program().revision(), legacyRevision); - persistedProgram = decoded.program().withRevision(programRevision); + ProgramV3Codec.LoadResult decoded = ProgramV3Codec.decode( + source, + worldPosition.toShortString(), + Computed.LOGGER::warn); + program = decoded.program(); + programRevision = program.revision(); unreadableProgramData = null; - hydrateFunctionCardsFromLibrary(); } catch (RuntimeException exception) { - dev.propulsionteam.computed.Computed.LOGGER.error( - "Could not load Computed program at {}; preserving its raw NBT with an empty runtime", + Computed.LOGGER.error( + "Could not load Computed format-3 program at {}; preserving raw program data", worldPosition, exception); - graph = new WGraph(); - functionDefinitions = new FunctionDefinitionStore(); - persistedProgram = null; - unreadableProgramData = copyProgramFields(tag); - programRevision = rawProgramRevision(tag); + program = ComputedProgramV3.empty(stableGraphId(worldPosition)); + programRevision = rawProgramRevision(source); + unreadableProgramData = copyProgramFields(source); } } private static CompoundTag copyProgramFields(CompoundTag source) { CompoundTag preserved = new CompoundTag(); - for (String key : List.of( - ProgramBridge.PROGRAM_TAG, - "ComputedProgramRevision", - "ComputerGraph", - "ComputerFunctions", - "formatVersion", - "revision", - "graph", - "functions", - "diagnostics", - "metadata", - "nodes", - "conns", - "sections", - "waypoints")) { + for (String key : List.of(PROGRAM_TAG, "formatVersion", "revision", "graph", "library", "states", "metadata")) { Tag value = source.get(key); - if (value != null) preserved.put(key, value.copy()); + if (value != null) { + preserved.put(key, value.copy()); + } } return preserved; } + private static boolean containsProgramData(CompoundTag source) { + return source.contains(PROGRAM_TAG, Tag.TAG_COMPOUND) + || source.contains("formatVersion") + || source.contains("ComputerGraph", Tag.TAG_COMPOUND) + || source.contains("ComputerFunctions") + || source.contains("graph", Tag.TAG_COMPOUND) + || source.contains("nodes"); + } + private static long rawProgramRevision(CompoundTag source) { - long revision = Math.max(0L, source.getLong("ComputedProgramRevision")); - revision = Math.max(revision, Math.max(0L, source.getLong("revision"))); - if (source.contains(ProgramBridge.PROGRAM_TAG, Tag.TAG_COMPOUND)) { - revision = Math.max( - revision, - Math.max(0L, source.getCompound(ProgramBridge.PROGRAM_TAG).getLong("revision"))); + long revision = Math.max(0, source.getLong("revision")); + if (source.contains(PROGRAM_TAG, Tag.TAG_COMPOUND)) { + revision = Math.max(revision, source.getCompound(PROGRAM_TAG).getLong("revision")); } return revision; } + private static UUID stableGraphId(BlockPos pos) { + return UUID.nameUUIDFromBytes( + ("computed:graph:" + pos.toShortString()).getBytes(StandardCharsets.UTF_8)); + } + + public Direction worldFaceForEndpoint(String name) { + return worldFace(name); + } + + private Direction worldFace(String name) { + if (name == null) { + return null; + } + Direction facing = getBlockState().hasProperty(ComputerBlock.FACING) + ? getBlockState().getValue(ComputerBlock.FACING) + : Direction.NORTH; + return switch (name.strip().toLowerCase(java.util.Locale.ROOT)) { + case "front" -> facing; + case "back" -> facing.getOpposite(); + case "left" -> facing.getCounterClockWise(); + case "right" -> facing.getClockWise(); + case "top", "up" -> Direction.UP; + case "bottom", "down" -> Direction.DOWN; + default -> null; + }; + } + + private static Widget toWidget(BuiltinWidget widget) { + Map properties = widget.properties(); + Widget raw = switch (widget.type()) { + case "text" -> new TextWidget( + widget.id(), + widget.x(), + widget.y(), + widget.width(), + widget.height(), + text(properties, "text"), + widget.color(), + alignment(properties)); + case "clock" -> new ClockWidget( + widget.id(), + widget.x(), + widget.y(), + widget.width(), + widget.height(), + widget.color(), + flag(properties, "show_seconds"), + alignment(properties)); + case "button" -> new ButtonWidget( + widget.id(), + widget.x(), + widget.y(), + widget.width(), + widget.height(), + text(properties, "label"), + widget.color()); + case "slider" -> new SliderWidget( + widget.id(), + widget.x(), + widget.y(), + widget.width(), + widget.height(), + number(properties, "value"), + number(properties, "minimum"), + number(properties, "maximum"), + widget.color(), + number(properties, "step")); + case "progress" -> new ProgressBarWidget( + widget.id(), + widget.x(), + widget.y(), + widget.width(), + widget.height(), + number(properties, "value"), + number(properties, "maximum"), + widget.color(), + (int) number(properties, "segments")); + default -> null; + }; + if (raw == null) { + return null; + } + LayoutManagedWidget.LayoutMode mode = + "manual".equalsIgnoreCase(text(properties, "layout_mode")) + ? LayoutManagedWidget.LayoutMode.MANUAL + : LayoutManagedWidget.LayoutMode.LINE; + LayoutManagedWidget.Fit fit = + "fill".equalsIgnoreCase(text(properties, "fit")) + ? LayoutManagedWidget.Fit.FILL + : LayoutManagedWidget.Fit.AUTO; + return new LayoutManagedWidget( + raw, + mode, + Math.max(1, (int) Math.round(number(properties, "line"))), + Math.max(1, Math.round(number(properties, "span"))), + fit); + } + + private static String text(Map properties, String key) { + Object value = properties.get(key); + return value instanceof String text ? text : ""; + } + + private static double number(Map properties, String key) { + Object value = properties.get(key); + return value instanceof Number number ? number.doubleValue() : 0; + } + + private static boolean flag(Map properties, String key) { + return Boolean.TRUE.equals(properties.get(key)); + } + + private static TextAlignment alignment(Map properties) { + return switch (text(properties, "alignment").toLowerCase(java.util.Locale.ROOT)) { + case "right" -> TextAlignment.RIGHT; + case "center" -> TextAlignment.CENTER; + default -> TextAlignment.LEFT; + }; + } + @Nullable @Override public ClientboundBlockEntityDataPacket getUpdatePacket() { diff --git a/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockItem.java b/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockItem.java index 3eee37d..6246244 100644 --- a/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockItem.java +++ b/src/main/java/dev/propulsionteam/computed/content/blocks/ComputerBlockItem.java @@ -13,7 +13,6 @@ import net.minecraft.world.level.block.Block; import java.util.List; -import dev.propulsionteam.computed.internal.node.ProgramBridge; public class ComputerBlockItem extends BlockItem { @@ -29,22 +28,15 @@ public void appendHoverText(ItemStack stack, TooltipContext context, List BlockEntityTicker getTicker(Level level, Block if (type != ComputedRegistries.MONITOR_BLOCK_ENTITY.get()) return null; return (lvl, pos, st, be) -> { if (be instanceof MonitorBlockEntity m) { + m.blockTick(); MonitorBlockEntity.serverTick(lvl, pos, st, m); } }; @@ -156,7 +157,7 @@ protected InteractionResult useWithoutItem(BlockState state, Level level, BlockP double usableW = Math.max(0.001, blocksW - inset * 2.0); double usableH = Math.max(0.001, blocksH - inset * 2.0); - Direction facing = origin.getDirection(); + Direction facing = origin.getFront(); Direction right = origin.getRight(); Direction gridDown = origin.getDown(); Vec3 originCorner = Vec3.atLowerCornerOf(origin.getBlockPos()); diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/create/CreateRedstoneLinkReceiverNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/create/CreateRedstoneLinkReceiverNode.java deleted file mode 100644 index 96b7e43..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/create/CreateRedstoneLinkReceiverNode.java +++ /dev/null @@ -1,106 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.create; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WCheckbox; -import dev.propulsionteam.computed.internal.node.api.elements.WFrequencySlotPair; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; -import net.minecraft.world.item.ItemStack; - -/** - * Listens on a Create redstone link frequency. {@code Level} is the analog link strength (0–15). With - * {@code Repeat while powered}, {@code Event} is a one-sample pulse on each logical graph step (e.g. once per - * server tick for the computer graph, independent of the Tick node's rate) while the link is powered; otherwise - * a one-tick pulse on a rising edge of link power. - */ -public final class CreateRedstoneLinkReceiverNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "create_redstone_link_receiver"); - - private final WFrequencySlotPair freqPair; - private final WCheckbox repeatWhile; - private boolean prevPowered; - private int linkStrength; - /** Last {@link WGraph#getSimulationStepCounter()} for which repeat mode emitted an Event pulse. */ - private int lastRepeatEmitStep = Integer.MIN_VALUE; - - public CreateRedstoneLinkReceiverNode(int x, int y) { - super(TYPE_ID, "Receiver", x, y); - addOutput("Event", 0xFF00FF88); - addOutput("Level", 0xFFFF6655); - addElement(new WLabel("Redstone Link")); - freqPair = new WFrequencySlotPair(); - addElement(freqPair); - repeatWhile = new WCheckbox("Repeat while powered"); - addElement(repeatWhile); - setEvaluator( - n -> { - // linkStrength is server-only state (Create's network handler calls us via the - // bridge). On the client, the editor runs its own copy of this evaluator each - // tick — overwriting the pin would clobber the value loaded from the server's - // NBT snapshot, leaving Display nodes stuck at 0. Use the snapshotted pin value - // when there's no host (= we're on the client). - int effective = ComputedGraphExecution.hostOrNull() == null - ? (int) Math.round(n.getOutputs().get(1).getValue()) - : linkStrength; - n.getOutputs().get(1).setValue(effective); - boolean on = effective > 0; - if (repeatWhile.isChecked()) { - if (!on) { - n.getOutputs().get(0).setValue(0.0); - lastRepeatEmitStep = Integer.MIN_VALUE; - } else { - WGraph g = n.evaluationGraph(); - if (g == null) { - n.getOutputs().get(0).setValue(0.0); - } else { - int step = g.getSimulationStepCounter(); - if (step != lastRepeatEmitStep) { - n.getOutputs().get(0).setValue(1.0); - lastRepeatEmitStep = step; - } else { - n.getOutputs().get(0).setValue(0.0); - } - } - } - } else { - n.getOutputs().get(0).setValue(on && !prevPowered ? 1.0 : 0.0); - } - prevPowered = on; - }); - } - - public ItemStack redFrequency() { - return freqPair.getRed(); - } - - public ItemStack blueFrequency() { - return freqPair.getBlue(); - } - - public void setLinkInputStrength(int strength) { - linkStrength = Mth.clamp(strength, 0, 15); - } - - public static final ResourceLocation MENU = ComputedMenuCategories.CREATE_REDSTONE_LINK; - public static final Component LABEL = Component.literal("Receiver"); - - /** Registers only the node type. Use when the Create menu category should not be added (e.g. Create mod absent). */ - public static void registerType() { - NodeRegistry.register(TYPE_ID, CreateRedstoneLinkReceiverNode::new); - } - - public static void register() { - registerType(); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/create/CreateRedstoneLinkSenderNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/create/CreateRedstoneLinkSenderNode.java deleted file mode 100644 index cca18fe..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/create/CreateRedstoneLinkSenderNode.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.create; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WFrequencySlotPair; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; -import net.minecraft.world.item.ItemStack; - -/** Transmits Create redstone link strength from Level when Tick is high. */ -public final class CreateRedstoneLinkSenderNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "create_redstone_link_sender"); - - private final WFrequencySlotPair freqPair; - - public CreateRedstoneLinkSenderNode(int x, int y) { - super(TYPE_ID, "Sender", x, y); - addInput("Tick", 0xFF00FF88); - addInput("Level", 0xFFFF6655); - addElement(new WLabel("Redstone Link")); - freqPair = new WFrequencySlotPair(); - addElement(freqPair); - setEvaluator(n -> {}); - } - - public ItemStack redFrequency() { - return freqPair.getRed(); - } - - public ItemStack blueFrequency() { - return freqPair.getBlue(); - } - - public int readTransmitStrength() { - if (getInputs().size() < 2) { - return 0; - } - if (getInputs().get(0).getValue() <= 0.5) { - return 0; - } - return Mth.clamp((int) Math.round(getInputs().get(1).getValue()), 0, 15); - } - - public static final ResourceLocation MENU = ComputedMenuCategories.CREATE_REDSTONE_LINK; - public static final Component LABEL = Component.literal("Sender"); - - /** Registers only the node type. Use when the Create menu category should not be added (e.g. Create mod absent). */ - public static void registerType() { - NodeRegistry.register(TYPE_ID, CreateRedstoneLinkSenderNode::new); - } - - public static void register() { - registerType(); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockLocationNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockLocationNode.java deleted file mode 100644 index 5d21b53..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockLocationNode.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import dev.ryanhcode.sable.companion.SableCompanion; -import net.minecraft.core.BlockPos; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.phys.Vec3; - -public final class BlockLocationNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "block_location"); - - public BlockLocationNode(int x, int y) { - super(TYPE_ID, "Block Location", x, y); - addOutput("X", 0xFFFF0000); - addOutput("Y", 0xFF00FF00); - addOutput("Z", 0xFF0000FF); - - addElement(new WLabel("Position of this computer")); - - setEvaluator(n -> { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return; - - BlockPos pos = host.getBlockPos(); - - Vec3 worldPos = SableCompanion.INSTANCE.projectOutOfSubLevel( - host.getLevel(), - (net.minecraft.core.Position) Vec3.atCenterOf(pos) - ); - - n.getOutputs().get(0).setValue(worldPos.x); - n.getOutputs().get(1).setValue(worldPos.y); - n.getOutputs().get(2).setValue(worldPos.z); - }); - } - - public static final ResourceLocation MENU = ComputedMenuCategories.VANILLA; - public static final Component LABEL = Component.literal("Block Location"); - - public static void register() { - NodeRegistry.register(TYPE_ID, BlockLocationNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} \ No newline at end of file diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockPresenceNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockPresenceNode.java deleted file mode 100644 index 8261d34..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockPresenceNode.java +++ /dev/null @@ -1,93 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WDropdown; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlock; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import java.util.List; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.state.BlockState; - -/** - * Emits 1.0 when the block at the chosen relative face is non-air, else 0.0. - */ -public final class BlockPresenceNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "block_presence"); - - private RelativeFace readFace = RelativeFace.FRONT; - private final WDropdown faceDropdown; - - public BlockPresenceNode(int x, int y) { - super(TYPE_ID, "Block Presence", x, y); - addOutput("Present", 0xFFFF5555); - faceDropdown = new WDropdown<>( - 88, - List.of(RelativeFace.values()), - f -> "Face: " + f.displayName(), - readFace, - f -> readFace = f); - addElement(faceDropdown); - addElement(new WLabel("1.0 if non-air at face")); - setEvaluator(n -> n.getOutputs().get(0).setValue(present() ? 1.0 : 0.0)); - } - - private boolean present() { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return false; - Level lvl = host.getLevel(); - if (lvl == null || lvl.isClientSide) return false; - BlockState selfState = host.getBlockState(); - if (!selfState.hasProperty(ComputerBlock.FACING)) return false; - Direction worldFace = readFace.toWorld(selfState.getValue(ComputerBlock.FACING)); - BlockPos neighbor = host.getBlockPos().relative(worldFace); - return !lvl.getBlockState(neighbor).isAir(); - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putString("computedReadFace", readFace.name()); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - if (tag.contains("computedReadFace")) { - String raw = tag.getString("computedReadFace"); - RelativeFace parsed = null; - try { - parsed = RelativeFace.valueOf(raw); - } catch (IllegalArgumentException ignored) { - Direction legacy = Direction.byName(raw); - if (legacy != null) { - parsed = RelativeFace.fromLegacyDirection(legacy); - } - } - if (parsed != null) { - readFace = parsed; - faceDropdown.setSelected(parsed); - } - } - } - - public static final ResourceLocation MENU = ComputedMenuCategories.VANILLA; - public static final Component LABEL = Component.literal("Block Presence"); - - public static void register() { - NodeRegistry.register(TYPE_ID, BlockPresenceNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockRotationNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockRotationNode.java deleted file mode 100644 index 4171a8b..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/BlockRotationNode.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import dev.ryanhcode.sable.companion.SableCompanion; -import dev.ryanhcode.sable.companion.SubLevelAccess; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import org.joml.Vector3d; - -public final class BlockRotationNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "block_rotation"); - - public BlockRotationNode(int x, int y) { - super(TYPE_ID, "Block Rotation", x, y); - addOutput("Yaw", 0xFFFF0000); - addOutput("Pitch", 0xFF00FF00); - addOutput("Roll", 0xFF0000FF); - - addElement(new WLabel("rotation of this computer")); - - setEvaluator(n -> { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return; - - SableCompanion sable = SableCompanion.INSTANCE; - - SubLevelAccess subLevel = sable.getContaining(host); - if (subLevel == null) return; - - Vector3d euler = new Vector3d(); - subLevel.logicalPose().orientation().getEulerAnglesYXZ(euler); - - n.getOutputs().get(0).setValue(Math.toDegrees(euler.y)); - n.getOutputs().get(1).setValue(Math.toDegrees(euler.x)); - n.getOutputs().get(2).setValue(Math.toDegrees(euler.z)); - }); - } - - public static final ResourceLocation MENU = ComputedMenuCategories.VANILLA; - public static final Component LABEL = Component.literal("Block Rotation"); - - public static void register() { - NodeRegistry.register(TYPE_ID, BlockRotationNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} \ No newline at end of file diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/CommandNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/CommandNode.java deleted file mode 100644 index 2e0cac0..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/CommandNode.java +++ /dev/null @@ -1,78 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import net.minecraft.commands.CommandSourceStack; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.phys.Vec3; - -public final class CommandNode extends WNode { - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "command"); - - public CommandNode(int x, int y) { - super(TYPE_ID, "Command", x, y); - addInput("Tick", 0xFFFF5555); - addInput("Command", WPin.DataType.STRING, 0xFF00FF88); - updateLayout(); - - setEvaluator(n -> { - if (n.getInputs().size() < 2) { - return; - } - // Safety gate: only run when a tick source is explicitly wired and active. - if (!n.getInputs().get(0).isConnected() || n.getInputs().get(0).getValue() <= 0.5) { - return; - } - String command = n.getInputs().get(1).getStringValue(); - runCommand(command); - }); - } - - private static int runCommand(String commandText) { - if (commandText == null || commandText.isBlank()) { - return 0; - } - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) { - return 0; - } - if (!(host.getLevel() instanceof ServerLevel level)) { - return 0; - } - MinecraftServer server = level.getServer(); - if (server == null) { - return 0; - } - String command = commandText.startsWith("/") ? commandText.substring(1) : commandText; - if (command.isBlank()) { - return 0; - } - Vec3 center = Vec3.atCenterOf(host.getBlockPos()); - CommandSourceStack source = server.createCommandSourceStack() - .withLevel(level) - .withPosition(center) - .withPermission(4) - .withSuppressedOutput(); - try { - server.getCommands().performPrefixedCommand(source, command); - return 1; - } catch (RuntimeException ex) { - return 0; - } - } - - public static void register() { - NodeRegistry.register(TYPE_ID, CommandNode::new); - NodeMenuRegistry.addNodeEntry(ComputedMenuCategories.CREATIVE, TYPE_ID, Component.literal("Command")); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/ComparatorReadNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/ComparatorReadNode.java deleted file mode 100644 index ea29311..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/ComparatorReadNode.java +++ /dev/null @@ -1,98 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WDropdown; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlock; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import java.util.List; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.state.BlockState; - -/** - * Reads the analog comparator output (0-15) of the block at a chosen relative face. Falls back to the weak - * redstone signal if the target block does not provide a comparator-style analog output. - */ -public final class ComparatorReadNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "comparator_read"); - - private RelativeFace readFace = RelativeFace.FRONT; - private final WDropdown faceDropdown; - - public ComparatorReadNode(int x, int y) { - super(TYPE_ID, "Comparator Read", x, y); - addOutput("Signal", 0xFFFFBB00); - faceDropdown = new WDropdown<>( - 88, - List.of(RelativeFace.values()), - f -> "Face: " + f.displayName(), - readFace, - f -> readFace = f); - addElement(faceDropdown); - addElement(new WLabel("Analog 0-15 (comparator)")); - setEvaluator(n -> n.getOutputs().get(0).setValue(readAnalog())); - } - - private int readAnalog() { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return 0; - Level lvl = host.getLevel(); - if (lvl == null || lvl.isClientSide) return 0; - BlockState selfState = host.getBlockState(); - if (!selfState.hasProperty(ComputerBlock.FACING)) return 0; - Direction worldFace = readFace.toWorld(selfState.getValue(ComputerBlock.FACING)); - BlockPos neighbor = host.getBlockPos().relative(worldFace); - BlockState target = lvl.getBlockState(neighbor); - if (target.hasAnalogOutputSignal()) { - return target.getAnalogOutputSignal(lvl, neighbor); - } - return lvl.getSignal(neighbor, worldFace); - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putString("computedReadFace", readFace.name()); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - if (tag.contains("computedReadFace")) { - String raw = tag.getString("computedReadFace"); - RelativeFace parsed = null; - try { - parsed = RelativeFace.valueOf(raw); - } catch (IllegalArgumentException ignored) { - Direction legacy = Direction.byName(raw); - if (legacy != null) { - parsed = RelativeFace.fromLegacyDirection(legacy); - } - } - if (parsed != null) { - readFace = parsed; - faceDropdown.setSelected(parsed); - } - } - } - - public static final ResourceLocation MENU = ComputedMenuCategories.VANILLA; - public static final Component LABEL = Component.literal("Comparator Read"); - - public static void register() { - NodeRegistry.register(TYPE_ID, ComparatorReadNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/ConcatenateTextNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/ConcatenateTextNode.java deleted file mode 100644 index 65585ab..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/ConcatenateTextNode.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class ConcatenateTextNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "concatenate_strings"); - - public ConcatenateTextNode(int x, int y) { - super(TYPE_ID, "Concatenate", x, y); - addInput("A", WPin.DataType.STRING, 0xFFFF0000); - addInput("B", WPin.DataType.STRING, 0xFF0000FF); - addOutput("text",WPin.DataType.STRING ,0xFF00FF00); - - addElement(new WLabel("A + B = AB")); - - setEvaluator(n -> { - String concatedString = n.getInputs().get(0).getStringValue() + n.getInputs().get(1).getStringValue(); - n.getOutputs().get(0).setStringValue(concatedString); - }); - } - - public static final ResourceLocation MENU = ComputedMenuCategories.VANILLA; - public static final Component LABEL = Component.literal("Concatenate Strings"); - - public static void register() { - NodeRegistry.register(TYPE_ID, ConcatenateTextNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} \ No newline at end of file diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/IfNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/IfNode.java deleted file mode 100644 index 349ba08..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/IfNode.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.Computed; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class IfNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "if_branch"); - - public IfNode(int x, int y) { - super(TYPE_ID, "If", x, y); - addInput("Condition", 0xFF00FF88); - addOutput("true", 0xFF55FF55); - addOutput("false", 0xFFFF5555); - - addElement(new WLabel(">0.5 -> true")); - - setEvaluator(n -> { - boolean cond = n.getInputs().get(0).getValue() > 0.5; - n.getOutputs().get(0).setValue(cond ? 1.0 : 0.0); - n.getOutputs().get(1).setValue(cond ? 0.0 : 1.0); - }); - } - - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_COMPARISON; - public static final Component LABEL = Component.literal("If"); - - public static void register() { - NodeRegistry.register(TYPE_ID, IfNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RedstoneInputNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RedstoneInputNode.java deleted file mode 100644 index 0ca1479..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RedstoneInputNode.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WDropdown; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlock; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import java.util.List; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.state.BlockState; - -/** - * Reads the weak redstone signal arriving at the chosen block-relative face of the computer - * and exposes it as a 0-15 Level output. - */ -public final class RedstoneInputNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "redstone_input"); - - private RelativeFace readFace = RelativeFace.FRONT; - private final WDropdown faceDropdown; - - public RedstoneInputNode(int x, int y) { - super(TYPE_ID, "Redstone Input", x, y); - addOutput("Level", 0xFFFF6655); - faceDropdown = new WDropdown<>( - 88, - List.of(RelativeFace.values()), - f -> "Face: " + f.displayName(), - readFace, - f -> readFace = f); - addElement(faceDropdown); - addElement(new WLabel("Reads 0-15 from neighbor", 0xFF888888)); - setEvaluator(n -> { - int level = readNeighborSignal(); - if (!n.getOutputs().isEmpty()) { - n.getOutputs().get(0).setValue(level); - } - }); - } - - public RelativeFace getReadFace() { - return readFace; - } - - private int readNeighborSignal() { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) { - return 0; - } - Level lvl = host.getLevel(); - if (lvl == null || lvl.isClientSide) { - return 0; - } - BlockState st = host.getBlockState(); - if (!st.hasProperty(ComputerBlock.FACING)) { - return 0; - } - Direction facing = st.getValue(ComputerBlock.FACING); - Direction worldFace = readFace.toWorld(facing); - BlockPos neighbor = host.getBlockPos().relative(worldFace); - return lvl.getSignal(neighbor, worldFace); - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putString("computedReadFace", readFace.name()); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - if (tag.contains("computedReadFace")) { - String raw = tag.getString("computedReadFace"); - RelativeFace parsed = null; - try { - parsed = RelativeFace.valueOf(raw); - } catch (IllegalArgumentException ignored) { - Direction legacy = Direction.byName(raw); - if (legacy != null) { - parsed = RelativeFace.fromLegacyDirection(legacy); - } - } - if (parsed != null) { - readFace = parsed; - faceDropdown.setSelected(parsed); - } - } - } - - public static final ResourceLocation MENU = ComputedMenuCategories.VANILLA; - public static final Component LABEL = Component.literal("Redstone Input"); - - public static void register() { - NodeRegistry.register(TYPE_ID, RedstoneInputNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RedstonePortNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RedstonePortNode.java deleted file mode 100644 index 436fe55..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RedstonePortNode.java +++ /dev/null @@ -1,81 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WDropdown; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import java.util.List; -import net.minecraft.core.Direction; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -/** - * Peripheral node: when Tick input > 0.5, emits weak redstone toward the chosen face of the computer block. - * The face is block-relative (Front/Back/Left/Right/Top/Bottom) and resolved against block facing at emit time. - */ -public final class RedstonePortNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "redstone_emitter"); - - private RelativeFace emitFace = RelativeFace.FRONT; - private final WDropdown faceDropdown; - - public RedstonePortNode(int x, int y) { - super(TYPE_ID, "Redstone Output", x, y); - addInput("Tick", 0xFF00FF88); - addInput("Level", 0xFFFF6655); - faceDropdown = new WDropdown<>( - 88, - List.of(RelativeFace.values()), - f -> "Face: " + f.displayName(), - emitFace, - f -> emitFace = f); - addElement(faceDropdown); - addElement(new WLabel("Weak power 0-15 to neighbor", 0xFF888888)); - setEvaluator(n -> {}); - } - - public RelativeFace getEmitFace() { - return emitFace; - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putString("computedEmitFace", emitFace.name()); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - if (tag.contains("computedEmitFace")) { - String raw = tag.getString("computedEmitFace"); - RelativeFace parsed = null; - try { - parsed = RelativeFace.valueOf(raw); - } catch (IllegalArgumentException ignored) { - Direction legacy = Direction.byName(raw); - if (legacy != null) { - parsed = RelativeFace.fromLegacyDirection(legacy); - } - } - if (parsed != null) { - emitFace = parsed; - faceDropdown.setSelected(parsed); - } - } - } - - public static final ResourceLocation MENU = ComputedMenuCategories.VANILLA; - public static final Component LABEL = Component.literal("Redstone Output"); - - public static void register() { - NodeRegistry.register(TYPE_ID, RedstonePortNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RelativeFace.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RelativeFace.java deleted file mode 100644 index 06b0550..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/RelativeFace.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import net.minecraft.core.Direction; - -public enum RelativeFace { - FRONT("Front"), - BACK("Back"), - LEFT("Left"), - RIGHT("Right"), - TOP("Top"), - BOTTOM("Bottom"); - - private final String displayName; - - RelativeFace(String displayName) { - this.displayName = displayName; - } - - public String displayName() { - return displayName; - } - - /** Resolve to a world {@link Direction} given the block's horizontal facing (front of block points to {@code facing}). */ - public Direction toWorld(Direction facing) { - return switch (this) { - case FRONT -> facing; - case BACK -> facing.getOpposite(); - case LEFT -> facing.getCounterClockWise(); - case RIGHT -> facing.getClockWise(); - case TOP -> Direction.UP; - case BOTTOM -> Direction.DOWN; - }; - } - - /** Parse from a case-insensitive string; returns {@code null} if unrecognised. */ - public static RelativeFace byName(String name) { - if (name == null) return null; - for (RelativeFace f : values()) { - if (f.name().equalsIgnoreCase(name) || f.displayName.equalsIgnoreCase(name)) return f; - } - return null; - } - - /** Best-effort migration from old world-absolute {@link Direction} dropdown values. */ - public static RelativeFace fromLegacyDirection(Direction d) { - return switch (d) { - case NORTH -> FRONT; - case SOUTH -> BACK; - case WEST -> LEFT; - case EAST -> RIGHT; - case UP -> TOP; - case DOWN -> BOTTOM; - }; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/SwitchNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/SwitchNode.java deleted file mode 100644 index 3eb671f..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/SwitchNode.java +++ /dev/null @@ -1,116 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WButton; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.Computed; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -import java.util.ArrayList; -import java.util.List; - -public final class SwitchNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "switch"); - - private static final int MIN_OUTPUTS = 2; - private static final int MAX_OUTPUTS = 16; - - private int outputCount = 2; - private final List caseFields = new ArrayList<>(); - - public SwitchNode(int x, int y) { - super(TYPE_ID, "Switch", x, y); - rebuildUiAndPins(); - setEvaluator(n -> { - String sel = n.getInputs().get(0).getStringValue(); - if (sel == null) sel = ""; - for (int i = 0; i < n.getOutputs().size(); i++) { - String expected = i < caseFields.size() ? caseFields.get(i).getValue() : ""; - n.getOutputs().get(i).setValue(sel.equals(expected) ? 1.0 : 0.0); - } - }); - } - - private void rebuildUiAndPins() { - getInputs().clear(); - getOutputs().clear(); - markPinSchemaChanged(); - getElements().clear(); - caseFields.clear(); - - addInput("Selector", WPin.DataType.STRING, 0xFF00FF88); - for (int i = 0; i < outputCount; i++) { - addOutput("Case " + i, 0xFFFF5555); - } - - addElement(new WLabel("Match selector -> case")); - for (int i = 0; i < outputCount; i++) { - WTextField field = new WTextField(50); - caseFields.add(field); - addElement(field); - } - addElement(new WButton("+ case", 40, () -> { - if (outputCount < MAX_OUTPUTS) { - List snapshot = snapshotCaseValues(); - outputCount++; - rebuildUiAndPins(); - restoreCaseValues(snapshot); - } - })); - addElement(new WButton("- case", 40, () -> { - if (outputCount > MIN_OUTPUTS) { - List snapshot = snapshotCaseValues(); - outputCount--; - rebuildUiAndPins(); - restoreCaseValues(snapshot); - } - })); - updateLayout(); - } - - private List snapshotCaseValues() { - List out = new ArrayList<>(caseFields.size()); - for (WTextField f : caseFields) out.add(f.getValue()); - return out; - } - - private void restoreCaseValues(List values) { - for (int i = 0; i < Math.min(values.size(), caseFields.size()); i++) { - caseFields.get(i).setValue(values.get(i)); - } - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putInt("outputCount", outputCount); - return tag; - } - - @Override - public void load(CompoundTag tag) { - if (tag.contains("outputCount")) { - outputCount = Mth.clamp(tag.getInt("outputCount"), MIN_OUTPUTS, MAX_OUTPUTS); - rebuildUiAndPins(); - } - super.load(tag); - } - - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_COMPARISON; - public static final Component LABEL = Component.literal("Switch"); - - public static void register() { - NodeRegistry.register(TYPE_ID, SwitchNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/WorldTimeNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/WorldTimeNode.java deleted file mode 100644 index 1213c9a..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/vanilla/WorldTimeNode.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.vanilla; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.level.Level; - -/** - * Exposes the host level's day-time as three outputs: raw ticks in [0, 24000), normalized phase in [0, 1), - * and a daylight boolean (1.0 when ticks < 12000). - */ -public final class WorldTimeNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "world_time"); - - public WorldTimeNode(int x, int y) { - super(TYPE_ID, "World Time", x, y); - addOutput("Ticks", 0xFFFFBB00); - addOutput("Phase", 0xFF88CCFF); - addOutput("IsDay", 0xFFFFFF66); - addElement(new WLabel("Day-time: ticks, 0-1 phase, isDay")); - setEvaluator(n -> { - long ticks = readDayTime(); - long inDay = ((ticks % 24000L) + 24000L) % 24000L; - n.getOutputs().get(0).setValue(inDay); - n.getOutputs().get(1).setValue(inDay / 24000.0); - n.getOutputs().get(2).setValue(inDay < 12000 ? 1.0 : 0.0); - }); - } - - private long readDayTime() { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return 0; - Level lvl = host.getLevel(); - if (lvl == null || lvl.isClientSide) return 0; - return lvl.getDayTime(); - } - - public static final ResourceLocation MENU = ComputedMenuCategories.VANILLA; - public static final Component LABEL = Component.literal("World Time"); - - public static void register() { - NodeRegistry.register(TYPE_ID, WorldTimeNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ButtonWidgetNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ButtonWidgetNode.java deleted file mode 100644 index c12cb47..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ButtonWidgetNode.java +++ /dev/null @@ -1,64 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.monitors.widgets.ButtonWidget; -import dev.propulsionteam.computed.content.monitors.widgets.LayoutManagedWidget; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -import java.util.concurrent.atomic.AtomicBoolean; - -public final class ButtonWidgetNode extends WNode implements InteractiveWidgetNode { - private final AtomicBoolean pendingPulse = new AtomicBoolean(false); - private final WidgetLayoutFields layout = - new WidgetLayoutFields(0, 0, 60, 20, LayoutManagedWidget.Fit.AUTO); - - public ButtonWidgetNode(int x, int y) { - super(WidgetNodeIds.BUTTON_WIDGET, "Button Widget", x, y); - layout.addTo(this); - addInput("Label", WPin.DataType.STRING, WPin.COLOR_STRING_DEFAULT); - addInput("Color", WPin.DataType.NUMBER, 0xFFFF66AA); - addOutput("Widget", WPin.DataType.WIDGET, WPin.COLOR_WIDGET_DEFAULT); - addOutput("Clicked", WPin.DataType.NUMBER, 0xFF00FF88); - setEvaluator(n -> { - String label = n.getInputs().get(0).getStringValue(); - int colorIn = (int) Math.round(n.getInputs().get(1).getValue()); - int color = colorIn == 0 ? 0xFF00FF88 : (colorIn | 0xFF000000); - n.getOutputs().get(0).setWidgetValue( - layout.wrap(new ButtonWidget(n.getId(), layout.x(), layout.y(), layout.width(), layout.height(), - label == null ? "" : label, color))); - n.getOutputs().get(1).setValue(pendingPulse.getAndSet(false) ? 1.0 : 0.0); - }); - } - - @Override - public void onWidgetInput(double value) { - pendingPulse.set(true); - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - layout.saveTo(tag); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - layout.loadFrom(tag); - } - - public static final ResourceLocation TYPE_ID = WidgetNodeIds.BUTTON_WIDGET; - public static final ResourceLocation MENU = ComputedMenuCategories.WIDGETS; - public static final Component LABEL = Component.literal("Button Widget"); - - public static void register() { - NodeRegistry.register(TYPE_ID, ButtonWidgetNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ClockWidgetNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ClockWidgetNode.java deleted file mode 100644 index d7fe8ef..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ClockWidgetNode.java +++ /dev/null @@ -1,96 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WDropdown; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.monitors.widgets.ClockWidget; -import dev.propulsionteam.computed.content.monitors.widgets.LayoutManagedWidget; -import dev.propulsionteam.computed.content.monitors.widgets.TextAlignment; -import java.util.List; -import java.util.Locale; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class ClockWidgetNode extends WNode { - public enum Format { HH_MM, HH_MM_SS; - @Override public String toString() { return this == HH_MM ? "HH:MM" : "HH:MM:SS"; } - } - - private Format format = Format.HH_MM; - private TextAlignment alignment = TextAlignment.CENTER; - private final WDropdown formatDropdown; - private final WDropdown alignmentDropdown; - private final WidgetLayoutFields layout = - new WidgetLayoutFields(0, 0, 60, 12, LayoutManagedWidget.Fit.AUTO); - - public ClockWidgetNode(int x, int y) { - super(WidgetNodeIds.CLOCK_WIDGET, "Clock Widget", x, y); - layout.addTo(this); - formatDropdown = new WDropdown<>( - 90, - List.of(Format.values()), - f -> "Format: " + f, - format, - f -> format = f); - addElement(formatDropdown); - alignmentDropdown = new WDropdown<>( - 92, - List.of(TextAlignment.values()), - a -> "Align: " + title(a.name()), - alignment, - a -> alignment = a); - addElement(alignmentDropdown); - addInput("Color", WPin.DataType.NUMBER, 0xFFFF66AA); - addOutput("Widget", WPin.DataType.WIDGET, WPin.COLOR_WIDGET_DEFAULT); - setEvaluator(n -> { - int colorIn = (int) Math.round(n.getInputs().get(0).getValue()); - int color = colorIn == 0 ? 0xFF00FF88 : (colorIn | 0xFF000000); - n.getOutputs().get(0).setWidgetValue( - layout.wrap(new ClockWidget(n.getId(), layout.x(), layout.y(), layout.width(), layout.height(), - color, format == Format.HH_MM_SS, alignment))); - }); - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putString("ClockFormat", format.name()); - tag.putString("ClockAlignment", alignment.name()); - layout.saveTo(tag); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - if (tag.contains("ClockFormat")) { - try { - format = Format.valueOf(tag.getString("ClockFormat")); - formatDropdown.setSelected(format); - } catch (IllegalArgumentException ignored) {} - } - if (tag.contains("ClockAlignment")) { - try { - alignment = TextAlignment.valueOf(tag.getString("ClockAlignment")); - alignmentDropdown.setSelected(alignment); - } catch (IllegalArgumentException ignored) {} - } - layout.loadFrom(tag); - } - - private static String title(String raw) { - return raw.charAt(0) + raw.substring(1).toLowerCase(Locale.ROOT); - } - - public static final ResourceLocation TYPE_ID = WidgetNodeIds.CLOCK_WIDGET; - public static final ResourceLocation MENU = ComputedMenuCategories.WIDGETS; - public static final Component LABEL = Component.literal("Clock Widget"); - - public static void register() { - NodeRegistry.register(TYPE_ID, ClockWidgetNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ColorSourceNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ColorSourceNode.java deleted file mode 100644 index 7fedc4c..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ColorSourceNode.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -/** - * Emits a 0xAARRGGBB color as a Number. Accepts a hex code such as {@code FF8800}, {@code #FF8800}, - * or {@code 0xFFFF8800}. Missing alpha defaults to fully opaque. - */ -public final class ColorSourceNode extends WNode { - private final WTextField hexField = new WTextField(80); - - public ColorSourceNode(int x, int y) { - super(WidgetNodeIds.COLOR_SOURCE, "Color", x, y); - hexField.setValue("FFFFFF"); - addOutput("Color", WPin.DataType.NUMBER, 0xFFFF66AA); - addElement(new WLabel("Hex (AARRGGBB or RRGGBB)", 0xFFAAAAAA)); - addElement(hexField); - setEvaluator(n -> n.getOutputs().get(0).setValue(parseColor(hexField.getValue()))); - } - - private static double parseColor(String raw) { - if (raw == null) return 0xFFFFFFFFL; - String s = raw.trim(); - if (s.startsWith("#")) s = s.substring(1); - if (s.startsWith("0x") || s.startsWith("0X")) s = s.substring(2); - if (s.isEmpty()) return 0xFFFFFFFFL; - try { - long v = Long.parseLong(s, 16); - if (s.length() <= 6) v |= 0xFF000000L; - return (double) (int) v; - } catch (NumberFormatException ignored) { - return 0xFFFFFFFFL; - } - } - - public static final ResourceLocation TYPE_ID = WidgetNodeIds.COLOR_SOURCE; - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Color"); - - public static void register() { - NodeRegistry.register(TYPE_ID, ColorSourceNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/InteractiveWidgetNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/InteractiveWidgetNode.java deleted file mode 100644 index 1e0ad6b..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/InteractiveWidgetNode.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -/** - * Implemented by widget nodes that receive user input from a monitor click. - * The widget id used for hit-testing is the node's own UUID, so the click handler can dispatch - * directly by looking up the node and casting. - */ -public interface InteractiveWidgetNode { - /** - * Called on the server when the player clicks the widget on a monitor. - * @param value normalized payload; for buttons the value is ignored (1.0 by convention); for sliders - * it is the click's local fraction along the bar in [0, 1]. - */ - void onWidgetInput(double value); -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/PeripheralNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/PeripheralNode.java deleted file mode 100644 index dbde914..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/PeripheralNode.java +++ /dev/null @@ -1,139 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WButton; -import dev.propulsionteam.computed.internal.node.api.elements.WDropdown; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlock; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import dev.propulsionteam.computed.content.monitors.MonitorBlockEntity; -import dev.propulsionteam.computed.content.monitors.widgets.MonitorWidgetLayout; -import dev.propulsionteam.computed.content.monitors.widgets.Widget; -import dev.propulsionteam.computed.content.monitors.widgets.WidgetDrawList; -import dev.propulsionteam.computed.content.nodes.vanilla.RelativeFace; -import dev.propulsionteam.computed.network.ComputedNetworking; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.util.Mth; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.entity.BlockEntity; - -import java.util.ArrayList; -import java.util.List; - -/** - * Binds an adjacent monitor (selected by side relative to the computer) to this computer and pushes the - * widgets connected to its inputs as a draw list every graph tick. - */ -public final class PeripheralNode extends WNode { - private static final int MIN_INPUTS = 1; - private static final int MAX_INPUTS = 16; - - private int inputCount = 1; - private RelativeFace face = RelativeFace.FRONT; - private WDropdown faceDropdown; - - public PeripheralNode(int x, int y) { - super(WidgetNodeIds.PERIPHERAL, "Monitor", x, y); - rebuildUiAndPins(); - setEvaluator(this::evaluateNode); - } - - private void rebuildUiAndPins() { - getInputs().clear(); - getOutputs().clear(); - markPinSchemaChanged(); - getElements().clear(); - - for (int i = 0; i < inputCount; i++) { - addInput("W" + (i + 1), WPin.DataType.WIDGET, WPin.COLOR_WIDGET_DEFAULT); - } - faceDropdown = new WDropdown<>( - 88, - List.of(RelativeFace.values()), - f -> "Face: " + f.displayName(), - face, - f -> face = f); - addElement(faceDropdown); - addElement(new WButton("+ widget", 60, () -> { - if (inputCount < MAX_INPUTS) { - inputCount++; - rebuildUiAndPins(); - } - })); - addElement(new WButton("- widget", 60, () -> { - if (inputCount > MIN_INPUTS) { - inputCount--; - rebuildUiAndPins(); - } - })); - addElement(new WLabel("Binds to monitor on chosen side", 0xFF888888)); - addElement(new WLabel("Screen px = monitor_blocks * 64", 0xFF888888)); - updateLayout(); - } - - private void evaluateNode(WNode n) { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return; - Level level = host.getLevel(); - if (level == null || level.isClientSide) return; - Direction worldFace = face.toWorld(host.getBlockState().getValue(ComputerBlock.FACING)); - BlockPos target = host.getBlockPos().relative(worldFace); - BlockEntity be = level.getBlockEntity(target); - if (!(be instanceof MonitorBlockEntity monitor)) return; - MonitorBlockEntity origin = monitor.findOrigin(); - if (origin == null) return; - - List widgets = new ArrayList<>(n.getInputs().size()); - for (var pin : n.getInputs()) { - if (!pin.isConnected()) continue; - Object v = pin.getWidgetValue(); - if (v instanceof Widget w) widgets.add(w); - } - int screenW = origin.getWidth() * ComputedNetworking.SCREEN_PX_PER_BLOCK; - int screenH = origin.getHeight() * ComputedNetworking.SCREEN_PX_PER_BLOCK; - widgets = MonitorWidgetLayout.resolve(widgets, screenW, screenH); - origin.bindOwner(host.getBlockPos()); - origin.setDrawList(new WidgetDrawList(widgets)); - } - - public RelativeFace getFace() { return face; } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putString("PeripheralFace", face.name()); - tag.putInt("inputCount", inputCount); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - if (tag.contains("inputCount")) { - inputCount = Mth.clamp(tag.getInt("inputCount"), MIN_INPUTS, MAX_INPUTS); - rebuildUiAndPins(); - } - super.load(tag); - if (tag.contains("PeripheralFace")) { - try { - face = RelativeFace.valueOf(tag.getString("PeripheralFace")); - faceDropdown.setSelected(face); - } catch (IllegalArgumentException ignored) {} - } - } - - public static final net.minecraft.resources.ResourceLocation TYPE_ID = WidgetNodeIds.PERIPHERAL; - public static final net.minecraft.resources.ResourceLocation MENU = ComputedMenuCategories.PERIPHERALS; - public static final net.minecraft.network.chat.Component LABEL = - net.minecraft.network.chat.Component.literal("Monitor"); - - public static void register() { - NodeRegistry.register(TYPE_ID, PeripheralNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ProgressBarWidgetNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ProgressBarWidgetNode.java deleted file mode 100644 index 63fb290..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/ProgressBarWidgetNode.java +++ /dev/null @@ -1,75 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.monitors.widgets.LayoutManagedWidget; -import dev.propulsionteam.computed.content.monitors.widgets.ProgressBarWidget; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class ProgressBarWidgetNode extends WNode { - private final WidgetLayoutFields layout = - new WidgetLayoutFields(0, 0, 80, 8, LayoutManagedWidget.Fit.AUTO); - private final WTextField segmentsField = new WTextField(60); - - public ProgressBarWidgetNode(int x, int y) { - super(WidgetNodeIds.PROGRESS_BAR_WIDGET, "Progress Bar Widget", x, y); - layout.addTo(this); - addElement(new WLabel("Segments (0 = solid)", 0xFFAAAAAA)); - segmentsField.setValue("0"); - addElement(segmentsField); - addInput("Value", WPin.DataType.NUMBER, 0xFFFFCC44); - addInput("Max", WPin.DataType.NUMBER, 0xFFFFCC44); - addInput("Color", WPin.DataType.NUMBER, 0xFFFF66AA); - addOutput("Widget", WPin.DataType.WIDGET, WPin.COLOR_WIDGET_DEFAULT); - setEvaluator(n -> { - double v = n.getInputs().get(0).getValue(); - double m = n.getInputs().get(1).getValue(); - if (m <= 0) m = 1; - int colorIn = (int) Math.round(n.getInputs().get(2).getValue()); - int color = colorIn == 0 ? 0xFF00FF88 : (colorIn | 0xFF000000); - int segs = parseSegments(); - n.getOutputs().get(0).setWidgetValue( - layout.wrap(new ProgressBarWidget(n.getId(), layout.x(), layout.y(), layout.width(), layout.height(), - v, m, color, segs))); - }); - } - - private int parseSegments() { - String s = segmentsField.getValue(); - if (s == null || s.isEmpty()) return 0; - try { - int v = Integer.parseInt(s.trim()); - return Math.max(0, v); - } catch (NumberFormatException e) { - return 0; - } - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - layout.saveTo(tag); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - layout.loadFrom(tag); - } - - public static final ResourceLocation TYPE_ID = WidgetNodeIds.PROGRESS_BAR_WIDGET; - public static final ResourceLocation MENU = ComputedMenuCategories.WIDGETS; - public static final Component LABEL = Component.literal("Progress Bar Widget"); - - public static void register() { - NodeRegistry.register(TYPE_ID, ProgressBarWidgetNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/SliderWidgetNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/SliderWidgetNode.java deleted file mode 100644 index 45fd98e..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/SliderWidgetNode.java +++ /dev/null @@ -1,91 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.monitors.widgets.LayoutManagedWidget; -import dev.propulsionteam.computed.content.monitors.widgets.SliderWidget; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -public final class SliderWidgetNode extends WNode implements InteractiveWidgetNode { - private volatile double normalized = 0.0; - private final WidgetLayoutFields layout = - new WidgetLayoutFields(0, 0, 80, 12, LayoutManagedWidget.Fit.AUTO); - private final WTextField stepField = new WTextField(60); - - public SliderWidgetNode(int x, int y) { - super(WidgetNodeIds.SLIDER_WIDGET, "Slider Widget", x, y); - layout.addTo(this); - addElement(new WLabel("Step (0 = none)", 0xFFAAAAAA)); - stepField.setValue("0"); - addElement(stepField); - addInput("Min", WPin.DataType.NUMBER, 0xFFFFCC44); - addInput("Max", WPin.DataType.NUMBER, 0xFFFFCC44); - addInput("Color", WPin.DataType.NUMBER, 0xFFFF66AA); - addOutput("Widget", WPin.DataType.WIDGET, WPin.COLOR_WIDGET_DEFAULT); - addOutput("Value", WPin.DataType.NUMBER, 0xFFFFCC44); - setEvaluator(n -> { - double min = n.getInputs().get(0).getValue(); - double max = n.getInputs().get(1).getValue(); - if (max < min) { double t = max; max = min; min = t; } - int colorIn = (int) Math.round(n.getInputs().get(2).getValue()); - int color = colorIn == 0 ? 0xFF00FF88 : (colorIn | 0xFF000000); - double v = min + Mth.clamp(normalized, 0.0, 1.0) * (max - min); - double step = parseStep(); - if (step > 0) { - v = min + Math.round((v - min) / step) * step; - v = Mth.clamp(v, min, max); - } - n.getOutputs().get(0).setWidgetValue( - layout.wrap(new SliderWidget(n.getId(), layout.x(), layout.y(), layout.width(), layout.height(), - v, min, max, color, step))); - n.getOutputs().get(1).setValue(v); - }); - } - - private double parseStep() { - String s = stepField.getValue(); - if (s == null || s.isEmpty()) return 0.0; - try { - double v = Double.parseDouble(s.trim()); - return v > 0 ? v : 0.0; - } catch (NumberFormatException e) { - return 0.0; - } - } - - @Override - public void onWidgetInput(double value) { - normalized = Mth.clamp(value, 0.0, 1.0); - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putDouble("Normalized", normalized); - layout.saveTo(tag); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - if (tag.contains("Normalized")) normalized = tag.getDouble("Normalized"); - layout.loadFrom(tag); - } - - public static final ResourceLocation TYPE_ID = WidgetNodeIds.SLIDER_WIDGET; - public static final ResourceLocation MENU = ComputedMenuCategories.WIDGETS; - public static final Component LABEL = Component.literal("Slider Widget"); - - public static void register() { - NodeRegistry.register(TYPE_ID, SliderWidgetNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/TextSourceNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/TextSourceNode.java deleted file mode 100644 index d117e6e..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/TextSourceNode.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class TextSourceNode extends WNode { - private final WTextField textField; - - public TextSourceNode(int x, int y) { - super(WidgetNodeIds.TEXT_SOURCE, "Text", x, y); - addOutput("Text", WPin.DataType.STRING, WPin.COLOR_STRING_DEFAULT); - textField = new WTextField(160); - addElement(new WLabel("Text", 0xFFAAAAAA)); - addElement(textField); - setEvaluator(n -> n.getOutputs().get(0).setStringValue(textField.getValue())); - } - - public static final ResourceLocation TYPE_ID = WidgetNodeIds.TEXT_SOURCE; - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Text"); - - public static void register() { - NodeRegistry.register(TYPE_ID, TextSourceNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/TextWidgetNode.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/TextWidgetNode.java deleted file mode 100644 index 3ee4de8..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/TextWidgetNode.java +++ /dev/null @@ -1,78 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WDropdown; -import dev.propulsionteam.computed.content.ComputedMenuCategories; -import dev.propulsionteam.computed.content.monitors.widgets.LayoutManagedWidget; -import dev.propulsionteam.computed.content.monitors.widgets.TextAlignment; -import dev.propulsionteam.computed.content.monitors.widgets.TextWidget; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -import java.util.List; - -public final class TextWidgetNode extends WNode { - private TextAlignment alignment = TextAlignment.CENTER; - private final WDropdown alignmentDropdown; - private final WidgetLayoutFields layout = - new WidgetLayoutFields(0, 0, 64, 12, LayoutManagedWidget.Fit.AUTO); - - public TextWidgetNode(int x, int y) { - super(WidgetNodeIds.TEXT_WIDGET, "Text Widget", x, y); - layout.addTo(this); - alignmentDropdown = new WDropdown<>( - 92, - List.of(TextAlignment.values()), - a -> "Align: " + title(a.name()), - alignment, - a -> alignment = a); - addElement(alignmentDropdown); - addInput("Text", WPin.DataType.STRING, WPin.COLOR_STRING_DEFAULT); - addInput("Color", WPin.DataType.NUMBER, 0xFFFF66AA); - addOutput("Widget", WPin.DataType.WIDGET, WPin.COLOR_WIDGET_DEFAULT); - setEvaluator(n -> { - String text = n.getInputs().get(0).getStringValue(); - int colorIn = (int) Math.round(n.getInputs().get(1).getValue()); - int color = colorIn == 0 ? 0xFF00FF88 : (colorIn | 0xFF000000); - n.getOutputs().get(0).setWidgetValue( - layout.wrap(new TextWidget(n.getId(), layout.x(), layout.y(), layout.width(), layout.height(), - text == null ? "" : text, color, alignment))); - }); - } - - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putString("TextAlignment", alignment.name()); - layout.saveTo(tag); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - super.load(tag); - if (tag.contains("TextAlignment")) { - try { - alignment = TextAlignment.valueOf(tag.getString("TextAlignment")); - alignmentDropdown.setSelected(alignment); - } catch (IllegalArgumentException ignored) {} - } - layout.loadFrom(tag); - } - - private static String title(String raw) { - return raw.charAt(0) + raw.substring(1).toLowerCase(java.util.Locale.ROOT); - } - - public static final ResourceLocation TYPE_ID = WidgetNodeIds.TEXT_WIDGET; - public static final ResourceLocation MENU = ComputedMenuCategories.WIDGETS; - public static final Component LABEL = Component.literal("Text Widget"); - - public static void register() { - NodeRegistry.register(TYPE_ID, TextWidgetNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetGeometryFields.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetGeometryFields.java deleted file mode 100644 index a5e5d5e..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetGeometryFields.java +++ /dev/null @@ -1,46 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; - -/** - * Four inline numeric text fields (x, y, w, h) for a widget node, plus convenience parsing. - * Each field is its own {@code WElement} so values persist through {@link WNode#save()}/{@link WNode#load(net.minecraft.nbt.CompoundTag)}. - */ -public final class WidgetGeometryFields { - public final WTextField xField = new WTextField(60); - public final WTextField yField = new WTextField(60); - public final WTextField wField = new WTextField(60); - public final WTextField hField = new WTextField(60); - - public WidgetGeometryFields(int defaultX, int defaultY, int defaultW, int defaultH) { - xField.setValue(Integer.toString(defaultX)); - yField.setValue(Integer.toString(defaultY)); - wField.setValue(Integer.toString(defaultW)); - hField.setValue(Integer.toString(defaultH)); - } - - /** Adds the four fields (with a small label) to the node, in order. */ - public void addTo(WNode node) { - node.addElement(new WLabel("X / Y / W / H", 0xFFAAAAAA)); - node.addElement(xField); - node.addElement(yField); - node.addElement(wField); - node.addElement(hField); - } - - public int x() { return parseOr(xField.getValue(), 0); } - public int y() { return parseOr(yField.getValue(), 0); } - public int width() { return Math.max(1, parseOr(wField.getValue(), 50)); } - public int height() { return Math.max(1, parseOr(hField.getValue(), 12)); } - - private static int parseOr(String s, int fallback) { - if (s == null || s.isEmpty()) return fallback; - try { - return (int) Math.round(Double.parseDouble(s.trim())); - } catch (NumberFormatException ignored) { - return fallback; - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetLayoutFields.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetLayoutFields.java deleted file mode 100644 index 8f2d842..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetLayoutFields.java +++ /dev/null @@ -1,270 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WDropdown; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.content.monitors.widgets.LayoutManagedWidget; -import dev.propulsionteam.computed.content.monitors.widgets.Widget; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -/** - * Conditional layout editor: normal line controls by default, manual X/Y/W/H only in advanced mode. - */ -public final class WidgetLayoutFields { - private static final String NBT_MODE = "WidgetLayoutMode"; - private static final String NBT_FIT = "WidgetLayoutFit"; - - private LayoutManagedWidget.LayoutMode mode = LayoutManagedWidget.LayoutMode.LINE; - private LayoutManagedWidget.Fit fit; - private final WDropdown modeDropdown; - private final WDropdown fitDropdown; - private final LayoutElement element; - - public final WTextField xField = new WTextField(60); - public final WTextField yField = new WTextField(60); - public final WTextField wField = new WTextField(60); - public final WTextField hField = new WTextField(60); - public final WTextField lineField = new WTextField(60); - public final WTextField scaleField = new WTextField(60); - - public WidgetLayoutFields(int defaultX, int defaultY, int defaultW, int defaultH, - LayoutManagedWidget.Fit defaultFit) { - fit = defaultFit == null ? LayoutManagedWidget.Fit.AUTO : defaultFit; - xField.setValue(Integer.toString(defaultX)); - yField.setValue(Integer.toString(defaultY)); - wField.setValue(Integer.toString(defaultW)); - hField.setValue(Integer.toString(defaultH)); - lineField.setValue("1"); - scaleField.setValue("1"); - modeDropdown = new WDropdown<>( - 92, - List.of(LayoutManagedWidget.LayoutMode.values()), - m -> "Layout: " + title(m.name()), - mode, - m -> mode = m); - fitDropdown = new WDropdown<>( - 92, - List.of(LayoutManagedWidget.Fit.values()), - f -> "Fit: " + title(f.name()), - fit, - f -> fit = f); - element = new LayoutElement(); - element.refreshSize(); - } - - public void addTo(WNode node) { - node.addElement(element); - } - - public Widget wrap(Widget raw) { - return new LayoutManagedWidget(raw, mode, line(), scale(), fit); - } - - public int x() { return parseIntOr(xField.getValue(), 0); } - public int y() { return parseIntOr(yField.getValue(), 0); } - public int width() { return Math.max(1, parseIntOr(wField.getValue(), 50)); } - public int height() { return Math.max(1, parseIntOr(hField.getValue(), 12)); } - public int line() { return Math.max(1, parseIntOr(lineField.getValue(), 1)); } - - public double scale() { - return Math.max(1, parseIntOr(scaleField.getValue(), 1)); - } - - public void saveTo(CompoundTag tag) { - tag.putString(NBT_MODE, mode.name()); - tag.putString(NBT_FIT, fit.name()); - } - - public void loadFrom(CompoundTag tag) { - migrateLegacyGeometry(tag); - if (tag.contains(NBT_MODE)) { - try { - mode = LayoutManagedWidget.LayoutMode.valueOf(tag.getString(NBT_MODE)); - modeDropdown.setSelected(mode); - } catch (IllegalArgumentException ignored) {} - } - if (tag.contains(NBT_FIT)) { - try { - fit = LayoutManagedWidget.Fit.valueOf(tag.getString(NBT_FIT)); - fitDropdown.setSelected(fit); - } catch (IllegalArgumentException ignored) {} - } - element.refreshSize(); - } - - private void migrateLegacyGeometry(CompoundTag nodeTag) { - if (nodeTag.contains(NBT_MODE) || !nodeTag.contains("elements")) { - return; - } - ListTag elements = nodeTag.getList("elements", 10); - if (elements.size() < 5) { - return; - } - copyLegacyField(elements, 1, xField); - copyLegacyField(elements, 2, yField); - copyLegacyField(elements, 3, wField); - copyLegacyField(elements, 4, hField); - mode = LayoutManagedWidget.LayoutMode.MANUAL; - modeDropdown.setSelected(mode); - } - - private static void copyLegacyField(ListTag elements, int index, WTextField field) { - CompoundTag fieldTag = elements.getCompound(index); - if (fieldTag.contains("value")) { - field.setValue(fieldTag.getString("value")); - } - } - - private List visibleChildren() { - List children = new ArrayList<>(); - children.add(modeDropdown); - if (mode == LayoutManagedWidget.LayoutMode.MANUAL) { - children.add(new WLabel("X / Y / W / H", 0xFFAAAAAA)); - children.add(xField); - children.add(yField); - children.add(wField); - children.add(hField); - } else { - children.add(new WLabel("Line / Span / Fit", 0xFFAAAAAA)); - children.add(lineField); - children.add(scaleField); - children.add(fitDropdown); - } - return children; - } - - private static int parseIntOr(String s, int fallback) { - return (int) Math.round(parseDoubleOr(s, fallback)); - } - - private static double parseDoubleOr(String s, double fallback) { - if (s == null || s.isEmpty()) return fallback; - try { - return Double.parseDouble(s.trim()); - } catch (NumberFormatException ignored) { - return fallback; - } - } - - private static String title(String raw) { - return raw.charAt(0) + raw.substring(1).toLowerCase(Locale.ROOT); - } - - private final class LayoutElement extends WElement { - private LayoutElement() { - this.width = 96; - refreshSize(); - } - - private void refreshSize() { - int h = 0; - for (WElement child : visibleChildren()) { - h += child.getHeight(); - } - this.height = Math.max(1, h - padding * 2 - margin * 2); - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - refreshSize(); - int cy = y; - for (WElement child : visibleChildren()) { - child.render(graphics, x, cy, mouseX, mouseY, partialTick); - cy += child.getHeight(); - } - } - - @Override - public boolean handleMouseClick(double localX, double localY, int button) { - boolean handled = false; - double cy = 0; - for (WElement child : visibleChildren()) { - if (child.handleMouseClick(localX, localY - cy, button)) { - handled = true; - } - cy += child.getHeight(); - } - refreshSize(); - return handled; - } - - @Override - public boolean handleMouseRelease(double mouseX, double mouseY, int button) { - double cy = 0; - for (WElement child : visibleChildren()) { - child.handleMouseRelease(mouseX, mouseY - cy, button); - cy += child.getHeight(); - } - return false; - } - - @Override - public boolean handleKeyPress(int keyCode, int scanCode, int modifiers) { - for (WElement child : visibleChildren()) { - if (child.handleKeyPress(keyCode, scanCode, modifiers)) return true; - } - return false; - } - - @Override - public boolean handleCharTyped(char codePoint, int modifiers) { - for (WElement child : visibleChildren()) { - if (child.handleCharTyped(codePoint, modifiers)) return true; - } - return false; - } - - @Override - public boolean isFocused() { - for (WElement child : visibleChildren()) { - if (child.isFocused()) return true; - } - return false; - } - - @Override - public CompoundTag save() { - CompoundTag tag = new CompoundTag(); - tag.putString(NBT_MODE, mode.name()); - tag.putString(NBT_FIT, fit.name()); - tag.put("x", xField.save()); - tag.put("y", yField.save()); - tag.put("w", wField.save()); - tag.put("h", hField.save()); - tag.put("line", lineField.save()); - tag.put("scale", scaleField.save()); - return tag; - } - - @Override - public void load(CompoundTag tag) { - if (tag.contains(NBT_MODE)) { - try { - mode = LayoutManagedWidget.LayoutMode.valueOf(tag.getString(NBT_MODE)); - modeDropdown.setSelected(mode); - } catch (IllegalArgumentException ignored) {} - } - if (tag.contains(NBT_FIT)) { - try { - fit = LayoutManagedWidget.Fit.valueOf(tag.getString(NBT_FIT)); - fitDropdown.setSelected(fit); - } catch (IllegalArgumentException ignored) {} - } - if (tag.contains("x")) xField.load(tag.getCompound("x")); - if (tag.contains("y")) yField.load(tag.getCompound("y")); - if (tag.contains("w")) wField.load(tag.getCompound("w")); - if (tag.contains("h")) hField.load(tag.getCompound("h")); - if (tag.contains("line")) lineField.load(tag.getCompound("line")); - if (tag.contains("scale")) scaleField.load(tag.getCompound("scale")); - refreshSize(); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetNodeIds.java b/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetNodeIds.java deleted file mode 100644 index c779eb0..0000000 --- a/src/main/java/dev/propulsionteam/computed/content/nodes/widgets/WidgetNodeIds.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.propulsionteam.computed.content.nodes.widgets; - -import dev.propulsionteam.computed.Computed; -import net.minecraft.resources.ResourceLocation; - -public final class WidgetNodeIds { - public static final ResourceLocation TEXT_SOURCE = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "text_source"); - public static final ResourceLocation PERIPHERAL = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "peripheral"); - public static final ResourceLocation TEXT_WIDGET = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "text_widget"); - public static final ResourceLocation CLOCK_WIDGET = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "clock_widget"); - public static final ResourceLocation BUTTON_WIDGET = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "button_widget"); - public static final ResourceLocation SLIDER_WIDGET = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "slider_widget"); - public static final ResourceLocation PROGRESS_BAR_WIDGET = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "progress_bar_widget"); - public static final ResourceLocation COLOR_SOURCE = - ResourceLocation.fromNamespaceAndPath(Computed.MODID, "color_source"); - - private WidgetNodeIds() {} -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/ComputedCustomNodes.java b/src/main/java/dev/propulsionteam/computed/customnodes/ComputedCustomNodes.java deleted file mode 100644 index 85bfe1b..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/ComputedCustomNodes.java +++ /dev/null @@ -1,89 +0,0 @@ -package dev.propulsionteam.computed.customnodes; - -import dev.propulsionteam.computed.Computed; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import net.neoforged.fml.loading.FMLPaths; - -public final class ComputedCustomNodes { - private static final CustomNodeRegistrar REGISTRAR = new CustomNodeRegistrar(); - - private ComputedCustomNodes() {} - - public static Path rootPath() { - return FMLPaths.GAMEDIR.get().resolve("config").resolve(Computed.MODID).resolve("nodes"); - } - - /** Reads the raw JSON of every definition file under {@link #rootPath()} (server side, for sync to clients). */ - public static List readRawDefinitions() { - Path root = rootPath(); - List out = new ArrayList<>(); - if (!Files.isDirectory(root)) { - return out; - } - try (var stream = Files.walk(root)) { - stream.filter(Files::isRegularFile) - .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json")) - .sorted() - .forEach(p -> { - try { - out.add(Files.readString(p)); - } catch (IOException e) { - Computed.LOGGER.warn("[custom-nodes] could not read {} for sync", p, e); - } - }); - } catch (IOException e) { - Computed.LOGGER.warn("[custom-nodes] could not walk {} for sync", root, e); - } - return out; - } - - /** Replaces local custom-node registrations with the definitions received from the server. */ - public static CustomNodeRegistrar.ReloadSummary applyServerDefinitions(List rawDefinitions) { - CustomNodeLoader loader = new CustomNodeLoader(); - List defs = new ArrayList<>(); - for (String raw : rawDefinitions) { - try { - defs.add(loader.parseRaw(raw, "server-synced")); - } catch (Exception e) { - Computed.LOGGER.warn("[custom-nodes] skipping invalid synced definition: {}", e.getMessage()); - } - } - CustomNodeRegistrar.ReloadSummary summary = REGISTRAR.applyDefinitions(defs); - Computed.LOGGER.info( - "[custom-nodes] applied server definitions: loaded={}, skipped={}, errors={}", - summary.loaded(), summary.skipped(), summary.errors()); - return summary; - } - - public static CustomNodeRegistrar.ReloadSummary reload() { - Path root = rootPath(); - try { - Files.createDirectories(root); - } catch (Exception e) { - Computed.LOGGER.warn("Could not create custom node directory {}", root, e); - } - CustomNodeRegistrar.ReloadSummary summary = REGISTRAR.reload(root); - for (String message : summary.messages()) { - if (message.startsWith("ERROR ")) { - Computed.LOGGER.error("[custom-nodes] {}", message.substring(6)); - } else if (message.startsWith("WARN ")) { - Computed.LOGGER.warn("[custom-nodes] {}", message.substring(5)); - } else { - Computed.LOGGER.info("[custom-nodes] {}", message); - } - } - Computed.LOGGER.info( - "[custom-nodes] reload complete: loaded={}, skipped={}, warnings={}, errors={}, root={}", - summary.loaded(), - summary.skipped(), - summary.warnings(), - summary.errors(), - root); - return summary; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeDefinition.java b/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeDefinition.java deleted file mode 100644 index 76e76fe..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeDefinition.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.propulsionteam.computed.customnodes; - -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.customnodes.expr.Value; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.minecraft.resources.ResourceLocation; - -public record CustomNodeDefinition( - ResourceLocation id, - String label, - List menuPath, - List inputs, - List outputs, - Map constants, - List state, - Path sourceFile) { - - public record PinSpec(String name, int color, WPin.DataType dataType) { - public PinSpec(String name, int color) { - this(name, color, WPin.DataType.NUMBER); - } - } - - public record OutputSpec(String name, int color, String expression, WPin.DataType dataType) { - public OutputSpec(String name, int color, String expression) { - this(name, color, expression, WPin.DataType.NUMBER); - } - } - - /** A persistent state variable: holds its value across ticks, updated each tick. */ - public record StateSpec(String name, Value init, String updateExpression) {} -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeDiagnostics.java b/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeDiagnostics.java deleted file mode 100644 index 891d527..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeDiagnostics.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.propulsionteam.computed.customnodes; - -import java.util.ArrayList; -import java.util.List; - -public final class CustomNodeDiagnostics { - private final List warnings = new ArrayList<>(); - private final List errors = new ArrayList<>(); - - public void warn(String message) { - warnings.add(message); - } - - public void error(String message) { - errors.add(message); - } - - public List warnings() { - return List.copyOf(warnings); - } - - public List errors() { - return List.copyOf(errors); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeLoader.java b/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeLoader.java deleted file mode 100644 index a523d81..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeLoader.java +++ /dev/null @@ -1,234 +0,0 @@ -package dev.propulsionteam.computed.customnodes; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.customnodes.expr.EvalContext; -import dev.propulsionteam.computed.customnodes.expr.FunctionRegistry; -import dev.propulsionteam.computed.customnodes.expr.Value; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Pattern; -import net.minecraft.resources.ResourceLocation; - -public final class CustomNodeLoader { - private static final Gson GSON = new Gson(); - private static final Pattern SEGMENT = Pattern.compile("[a-z0-9_\\-./]+"); - - public record LoadResult(List definitions, CustomNodeDiagnostics diagnostics) {} - - public LoadResult load(Path root) { - CustomNodeDiagnostics diagnostics = new CustomNodeDiagnostics(); - List out = new ArrayList<>(); - if (!Files.isDirectory(root)) { - return new LoadResult(out, diagnostics); - } - try (var stream = Files.walk(root)) { - stream.filter(Files::isRegularFile) - .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json")) - .sorted() - .forEach(file -> parseFile(file, out, diagnostics)); - } catch (IOException e) { - diagnostics.error("Failed to walk " + root + ": " + e.getMessage()); - } - return new LoadResult(out, diagnostics); - } - - private static void parseFile(Path file, List out, CustomNodeDiagnostics diagnostics) { - try { - String raw = Files.readString(file); - JsonObject obj = JsonParser.parseString(raw).getAsJsonObject(); - CustomNodeDefinition def = parseDefinition(obj, file); - out.add(def); - } catch (Exception e) { - diagnostics.error(file + ": " + e.getMessage()); - } - } - - /** Parses one definition from raw JSON text (used for server→client sync, where there is no file on disk). */ - public CustomNodeDefinition parseRaw(String rawJson, String sourceLabel) { - JsonObject obj = JsonParser.parseString(rawJson).getAsJsonObject(); - return parseDefinition(obj, Path.of(sourceLabel)); - } - - private static CustomNodeDefinition parseDefinition(JsonObject obj, Path file) { - ResourceLocation id = ResourceLocation.parse(requiredString(obj, "id")); - String label = requiredString(obj, "label"); - List menuPath = parseMenuPath(obj.getAsJsonArray("menuPath")); - List inputs = parseInputs(obj.getAsJsonArray("inputs")); - List outputs = parseOutputs(obj.getAsJsonArray("outputs")); - Map constants = parseConstants(obj.getAsJsonObject("constants")); - List state = parseState(obj.getAsJsonArray("state")); - - if (outputs.isEmpty()) throw new IllegalArgumentException("outputs must not be empty"); - ensureUniquePinNames(inputs, outputs); - validateExpressions(outputs, inputs, constants, state); - return new CustomNodeDefinition(id, label, menuPath, inputs, outputs, constants, state, file); - } - - private static void validateExpressions( - List outputs, - List inputs, - Map constants, - List state) { - Map vars = new HashMap<>(); - for (CustomNodeDefinition.PinSpec pin : inputs) { - String key = pin.name().toLowerCase(Locale.ROOT); - vars.put(key, pin.dataType() == WPin.DataType.STRING ? Value.EMPTY_STRING : Value.ZERO); - } - for (Map.Entry e : constants.entrySet()) { - vars.put(e.getKey().toLowerCase(Locale.ROOT), Value.of(e.getValue())); - } - for (CustomNodeDefinition.StateSpec s : state) { - vars.put(s.name().toLowerCase(Locale.ROOT), s.init()); - } - Map stateStore = new HashMap<>(); - EvalContext ctx = new EvalContext(vars, stateStore, FunctionRegistry.get()); - - for (CustomNodeDefinition.StateSpec s : state) { - try { - ExpressionEvaluator.eval(s.updateExpression(), ctx); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid state update for '" + s.name() + "': " + e.getMessage()); - } - } - for (CustomNodeDefinition.OutputSpec out : outputs) { - try { - ExpressionEvaluator.eval(out.expression(), ctx); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid expression for output '" + out.name() + "': " + e.getMessage()); - } - } - } - - private static void ensureUniquePinNames( - List inputs, List outputs) { - java.util.Set seen = new java.util.HashSet<>(); - for (CustomNodeDefinition.PinSpec pin : inputs) { - if (!seen.add(pin.name().toLowerCase(Locale.ROOT))) - throw new IllegalArgumentException("Duplicate input name: " + pin.name()); - } - for (CustomNodeDefinition.OutputSpec pin : outputs) { - if (!seen.add(pin.name().toLowerCase(Locale.ROOT))) - throw new IllegalArgumentException("Duplicate output name: " + pin.name()); - } - } - - private static List parseInputs(JsonArray arr) { - List list = new ArrayList<>(); - if (arr == null) return list; - for (JsonElement el : arr) { - JsonObject o = el.getAsJsonObject(); - WPin.DataType dt = parsePinType(o, WPin.DataType.NUMBER); - int defaultColor = dt == WPin.DataType.STRING ? 0xFFFFC830 : 0xFF00FF88; - list.add(new CustomNodeDefinition.PinSpec(requiredString(o, "name"), parseColor(o, defaultColor), dt)); - } - return list; - } - - private static List parseOutputs(JsonArray arr) { - List list = new ArrayList<>(); - if (arr == null) return list; - for (JsonElement el : arr) { - JsonObject o = el.getAsJsonObject(); - WPin.DataType dt = parsePinType(o, WPin.DataType.NUMBER); - int defaultColor = dt == WPin.DataType.STRING ? 0xFFFFC830 : 0xFFFF5555; - list.add(new CustomNodeDefinition.OutputSpec( - requiredString(o, "name"), - parseColor(o, defaultColor), - requiredString(o, "expression"), - dt)); - } - return list; - } - - private static List parseState(JsonArray arr) { - List list = new ArrayList<>(); - if (arr == null) return list; - for (JsonElement el : arr) { - JsonObject o = el.getAsJsonObject(); - String name = requiredString(o, "name"); - // init can be number or string - Value init; - if (o.has("init")) { - JsonElement initEl = o.get("init"); - if (initEl.isJsonPrimitive() && initEl.getAsJsonPrimitive().isString()) { - init = Value.of(initEl.getAsString()); - } else { - init = Value.of(initEl.getAsDouble()); - } - } else { - init = Value.ZERO; - } - String update = o.has("update") ? o.get("update").getAsString() : name; - list.add(new CustomNodeDefinition.StateSpec(name, init, update)); - } - return list; - } - - private static WPin.DataType parsePinType(JsonObject obj, WPin.DataType fallback) { - if (!obj.has("type")) return fallback; - return switch (obj.get("type").getAsString().toLowerCase(Locale.ROOT)) { - case "string", "str", "text" -> WPin.DataType.STRING; - default -> WPin.DataType.NUMBER; - }; - } - - private static Map parseConstants(JsonObject obj) { - Map constants = new LinkedHashMap<>(); - if (obj == null) return constants; - for (Map.Entry entry : obj.entrySet()) { - String key = entry.getKey().trim().toLowerCase(Locale.ROOT); - if (key.isEmpty()) throw new IllegalArgumentException("constants key is blank"); - constants.put(key, entry.getValue().getAsDouble()); - } - return constants; - } - - private static int parseColor(JsonObject obj, int fallback) { - if (!obj.has("color")) return fallback; - String s = obj.get("color").getAsString().trim(); - if (s.startsWith("#")) s = s.substring(1); - if (s.length() == 6) return (0xFF << 24) | Integer.parseUnsignedInt(s, 16); - if (s.length() == 8) return (int) Long.parseLong(s, 16); - throw new IllegalArgumentException("Invalid color hex: " + obj.get("color").getAsString()); - } - - private static List parseMenuPath(JsonArray arr) { - List segments = new ArrayList<>(); - if (arr == null || arr.isEmpty()) { - segments.add("Custom"); - return segments; - } - for (JsonElement el : arr) { - String segment = el.getAsString().trim(); - if (segment.isEmpty()) throw new IllegalArgumentException("menuPath contains blank segment"); - String normalized = normalizeSegment(segment); - if (!SEGMENT.matcher(normalized).matches()) - throw new IllegalArgumentException("menuPath segment contains unsupported chars: " + segment); - segments.add(segment); - } - return segments; - } - - static String normalizeSegment(String value) { - return value.trim().toLowerCase(Locale.ROOT).replace(' ', '_'); - } - - private static String requiredString(JsonObject obj, String key) { - if (!obj.has(key)) throw new IllegalArgumentException("Missing required field: " + key); - String value = GSON.fromJson(obj.get(key), String.class); - if (value == null || value.trim().isEmpty()) throw new IllegalArgumentException("Field " + key + " is blank"); - return value.trim(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeRegistrar.java b/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeRegistrar.java deleted file mode 100644 index 521a409..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/CustomNodeRegistrar.java +++ /dev/null @@ -1,105 +0,0 @@ -package dev.propulsionteam.computed.customnodes; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.Computed; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class CustomNodeRegistrar { - private final Set registeredTypeIds = new LinkedHashSet<>(); - private final Set registeredCategoryIds = new LinkedHashSet<>(); - - public record ReloadSummary(int loaded, int skipped, int warnings, int errors, List messages) {} - - public ReloadSummary reload(Path root) { - CustomNodeLoader.LoadResult load = new CustomNodeLoader().load(root); - List messages = new ArrayList<>(); - for (String warning : load.diagnostics().warnings()) { - messages.add("WARN " + warning); - } - for (String error : load.diagnostics().errors()) { - messages.add("ERROR " + error); - } - return applyDefinitions(load.definitions(), messages); - } - - /** Replaces all custom registrations with the given definitions (used for server→client sync). */ - public ReloadSummary applyDefinitions(List definitions) { - return applyDefinitions(definitions, new ArrayList<>()); - } - - private ReloadSummary applyDefinitions(List definitions, List messages) { - clearCustomRegistrations(); - int loaded = 0; - int skipped = 0; - for (CustomNodeDefinition def : definitions) { - if (NodeRegistry.isRegistered(def.id())) { - skipped++; - messages.add("WARN " + def.sourceFile() + ": id already registered: " + def.id()); - continue; - } - try { - ResourceLocation menuId = ensureBuiltinNodeCategories(def.menuPath()); - NodeRegistry.register(def.id(), (x, y) -> new CustomRuntimeNode(def, x, y)); - NodeMenuRegistry.addNodeEntry(menuId, def.id(), Component.literal(def.label())); - registeredTypeIds.add(def.id()); - loaded++; - } catch (Exception e) { - skipped++; - messages.add("ERROR " + def.sourceFile() + ": " + e.getMessage()); - } - } - int errors = (int) messages.stream().filter(m -> m.startsWith("ERROR ")).count(); - int warnings = (int) messages.stream().filter(m -> m.startsWith("WARN ")).count(); - return new ReloadSummary(loaded, skipped, warnings, errors, messages); - } - - public void clearCustomRegistrations() { - NodeMenuRegistry.removeNodeEntriesForTypes(registeredTypeIds); - for (ResourceLocation typeId : registeredTypeIds) { - NodeRegistry.unregister(typeId); - } - NodeMenuRegistry.removeCategories(registeredCategoryIds); - registeredTypeIds.clear(); - registeredCategoryIds.clear(); - } - - private ResourceLocation ensureBuiltinNodeCategories(List rawPath) { - ResourceLocation root = rootCategory(); - ResourceLocation parent = root; - StringBuilder pathKey = new StringBuilder(); - if (NodeMenuRegistry.getCategory(root) == null) { - NodeMenuRegistry.registerCategory(root, Component.literal("Custom"), NodeMenuRegistry.ROOT); - registeredCategoryIds.add(root); - } - for (String segment : rawPath) { - if ("custom".equalsIgnoreCase(segment)) { - continue; - } - String normalized = CustomNodeLoader.normalizeSegment(segment); - if (!pathKey.isEmpty()) { - pathKey.append("/"); - } - pathKey.append(normalized); - ResourceLocation id = ResourceLocation.fromNamespaceAndPath( - Computed.MODID, "menu_custom/" + pathKey.toString().toLowerCase(Locale.ROOT)); - if (NodeMenuRegistry.getCategory(id) == null) { - NodeMenuRegistry.registerCategory(id, Component.literal(segment), parent); - registeredCategoryIds.add(id); - } - parent = id; - } - return parent; - } - - private static ResourceLocation rootCategory() { - return ResourceLocation.fromNamespaceAndPath(Computed.MODID, "menu_custom"); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/CustomRuntimeNode.java b/src/main/java/dev/propulsionteam/computed/customnodes/CustomRuntimeNode.java deleted file mode 100644 index 18f563e..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/CustomRuntimeNode.java +++ /dev/null @@ -1,135 +0,0 @@ -package dev.propulsionteam.computed.customnodes; - -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.customnodes.expr.EvalContext; -import dev.propulsionteam.computed.customnodes.expr.FunctionRegistry; -import dev.propulsionteam.computed.customnodes.expr.Value; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import net.minecraft.nbt.CompoundTag; - -final class CustomRuntimeNode extends WNode { - private final CustomNodeDefinition definition; - /** Persistent state — keyed by state var name + call-site counters. Survives across ticks. */ - private final Map stateStore = new HashMap<>(); - - CustomRuntimeNode(CustomNodeDefinition definition, int x, int y) { - super(definition.id(), definition.label(), x, y); - this.definition = definition; - for (CustomNodeDefinition.PinSpec pin : definition.inputs()) { - addInput(pin.name(), pin.dataType(), pin.color()); - } - for (CustomNodeDefinition.OutputSpec out : definition.outputs()) { - addOutput(out.name(), out.dataType(), out.color()); - } - addElement(new WLabel(definition.label())); - - // Seed state store with initial values - for (CustomNodeDefinition.StateSpec s : definition.state()) { - stateStore.put(stateKey(s.name()), s.init()); - } - - setEvaluator(node -> evaluate((CustomRuntimeNode) node)); - } - - @Override - public boolean isStateBoundary() { - return !definition.state().isEmpty(); - } - - private static void evaluate(CustomRuntimeNode node) { - CustomNodeDefinition def = node.definition; - Map storeRef = node.stateStore; - - // Build vars: inputs + constants - Map vars = new HashMap<>(); - for (int i = 0; i < def.inputs().size() && i < node.getInputs().size(); i++) { - String key = def.inputs().get(i).name().toLowerCase(Locale.ROOT); - WPin pin = node.getInputs().get(i); - vars.put(key, pin.getDataType() == WPin.DataType.STRING - ? Value.of(pin.getStringValue()) - : Value.of(pin.getValue())); - } - for (Map.Entry entry : def.constants().entrySet()) { - vars.put(entry.getKey().toLowerCase(Locale.ROOT), Value.of(entry.getValue())); - } - // Expose current state vars as readable variables - for (CustomNodeDefinition.StateSpec s : def.state()) { - Value cur = storeRef.getOrDefault(stateKey(s.name()), s.init()); - vars.put(s.name().toLowerCase(Locale.ROOT), cur); - } - - EvalContext ctx = new EvalContext(vars, storeRef, FunctionRegistry.get()); - - // Update state vars (each update sees the *pre-tick* snapshot) - if (!def.state().isEmpty()) { - Map snapshot = ctx.snapshotVars(); - for (CustomNodeDefinition.StateSpec s : def.state()) { - // Re-evaluate with snapshot so updates are independent of each other - EvalContext snapCtx = new EvalContext(snapshot, storeRef, FunctionRegistry.get()); - Value updated = ExpressionEvaluator.eval(s.updateExpression(), snapCtx); - storeRef.put(stateKey(s.name()), updated); - // Update live var so outputs see new value - ctx.setVar(s.name().toLowerCase(Locale.ROOT), updated); - } - } - - // Evaluate outputs - ctx.resetCallSiteIndex(); - for (int i = 0; i < def.outputs().size() && i < node.getOutputs().size(); i++) { - Value result = ExpressionEvaluator.eval(def.outputs().get(i).expression(), ctx); - WPin out = node.getOutputs().get(i); - if (out.getDataType() == WPin.DataType.STRING) { - out.setStringValue(result.asString()); - } else { - out.setValue(result.asNumber()); - } - } - } - - // --- NBT persistence --- - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - CompoundTag stateTag = new CompoundTag(); - for (Map.Entry entry : stateStore.entrySet()) { - CompoundTag vTag = new CompoundTag(); - vTag.putString("t", entry.getValue().type().name()); - if (entry.getValue().isNumber()) { - vTag.putDouble("v", entry.getValue().asNumber()); - } else { - vTag.putString("v", entry.getValue().asString()); - } - stateTag.put(entry.getKey(), vTag); - } - tag.put("computedState", stateTag); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - if (tag.contains("computedState")) { - CompoundTag stateTag = tag.getCompound("computedState"); - for (String key : stateTag.getAllKeys()) { - CompoundTag vTag = stateTag.getCompound(key); - String typeStr = vTag.getString("t"); - Value v; - if ("STRING".equals(typeStr)) { - v = Value.of(vTag.getString("v")); - } else { - v = Value.of(vTag.getDouble("v")); - } - stateStore.put(key, v); - } - } - } - - private static String stateKey(String name) { - return "state_" + name.toLowerCase(Locale.ROOT); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/ExpressionEvaluator.java b/src/main/java/dev/propulsionteam/computed/customnodes/ExpressionEvaluator.java deleted file mode 100644 index 4481326..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/ExpressionEvaluator.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.propulsionteam.computed.customnodes; - -import dev.propulsionteam.computed.customnodes.expr.EvalContext; -import dev.propulsionteam.computed.customnodes.expr.FunctionRegistry; -import dev.propulsionteam.computed.customnodes.expr.Lexer; -import dev.propulsionteam.computed.customnodes.expr.Parser; -import dev.propulsionteam.computed.customnodes.expr.Value; -import java.util.HashMap; -import java.util.Map; - -public final class ExpressionEvaluator { - private ExpressionEvaluator() {} - - /** Full-featured entry point: typed Value result, stateful context, source functions. */ - public static Value eval(String program, EvalContext ctx) { - Parser parser = new Parser(Lexer.tokenize(program), ctx); - return parser.parseProgram(); - } - - /** Legacy numeric-only entry point used during load-time validation. */ - public static double eval(String expression, Map vars) { - Map valueVars = new HashMap<>(); - for (Map.Entry e : vars.entrySet()) { - valueVars.put(e.getKey(), Value.of(e.getValue())); - } - Map state = new HashMap<>(); - EvalContext ctx = new EvalContext(valueVars, state, FunctionRegistry.get()); - return eval(expression, ctx).asNumber(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/expr/EvalContext.java b/src/main/java/dev/propulsionteam/computed/customnodes/expr/EvalContext.java deleted file mode 100644 index 971f5e9..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/expr/EvalContext.java +++ /dev/null @@ -1,67 +0,0 @@ -package dev.propulsionteam.computed.customnodes.expr; - -import java.util.HashMap; -import java.util.Map; - -public final class EvalContext { - private final Map vars; - private final Map state; - private final FunctionRegistry functions; - private int callSiteIndex = 0; - - public EvalContext(Map vars, Map state, FunctionRegistry functions) { - this.vars = new HashMap<>(vars); - this.state = state; - this.functions = functions; - } - - // --- Variable access --- - - public Value getVar(String name) { - return vars.get(name); - } - - public boolean hasVar(String name) { - return vars.containsKey(name); - } - - /** Set or shadow a local variable (from assignment statements). */ - public void setVar(String name, Value value) { - vars.put(name, value); - } - - // --- Persistent state --- - - public Value getState(String key, Value defaultValue) { - return state.getOrDefault(key, defaultValue); - } - - public void setState(String key, Value value) { - state.put(key, value); - } - - // --- Call-site counter (resets each tick via resetCallSiteIndex) --- - - public int nextCallSiteIndex() { - return callSiteIndex++; - } - - public void resetCallSiteIndex() { - callSiteIndex = 0; - } - - // --- Function dispatch --- - - public boolean hasFunction(String name) { - return functions.has(name); - } - - public Value callFunction(String name, java.util.List args) { - return functions.call(name, args, this); - } - - /** Snapshot current vars (used before running state update expressions). */ - public Map snapshotVars() { - return new HashMap<>(vars); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/expr/ExprFunction.java b/src/main/java/dev/propulsionteam/computed/customnodes/expr/ExprFunction.java deleted file mode 100644 index a2ecd74..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/expr/ExprFunction.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.propulsionteam.computed.customnodes.expr; - -import java.util.List; - -@FunctionalInterface -public interface ExprFunction { - Value call(List args, EvalContext ctx); -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/expr/FunctionRegistry.java b/src/main/java/dev/propulsionteam/computed/customnodes/expr/FunctionRegistry.java deleted file mode 100644 index efb6887..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/expr/FunctionRegistry.java +++ /dev/null @@ -1,189 +0,0 @@ -package dev.propulsionteam.computed.customnodes.expr; - -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -public final class FunctionRegistry { - private static final FunctionRegistry INSTANCE = new FunctionRegistry(); - - private final Map functions = new HashMap<>(); - - private FunctionRegistry() { - registerBuiltins(); - } - - public static FunctionRegistry get() { - return INSTANCE; - } - - public void register(String name, ExprFunction fn) { - functions.put(name.toLowerCase(Locale.ROOT), fn); - } - - public boolean has(String name) { - return functions.containsKey(name.toLowerCase(Locale.ROOT)); - } - - public Value call(String name, List args, EvalContext ctx) { - ExprFunction fn = functions.get(name.toLowerCase(Locale.ROOT)); - if (fn == null) throw new IllegalArgumentException("Unknown function: " + name); - return fn.call(args, ctx); - } - - private void registerBuiltins() { - // Math — 2-arg - register("min", (a, c) -> Value.of(Math.min(num(a,0,"min"), num(a,1,"min")))); - register("max", (a, c) -> Value.of(Math.max(num(a,0,"max"), num(a,1,"max")))); - register("pow", (a, c) -> Value.of(Math.pow(num(a,0,"pow"), num(a,1,"pow")))); - register("atan2", (a, c) -> Value.of(Math.atan2(num(a,0,"atan2"), num(a,1,"atan2")))); - register("log", (a, c) -> { - double x = num(a, 0, "log"); - if (a.size() == 2) { - double base = num(a, 1, "log"); - return Value.of(base <= 0 ? 0.0 : Math.log(Math.max(x, 1e-300)) / Math.log(base)); - } - return Value.of(Math.log(Math.max(x, 1e-300))); - }); - register("hypot", (a, c) -> Value.of(Math.hypot(num(a,0,"hypot"), num(a,1,"hypot")))); - register("clamp", (a, c) -> { - double v = num(a, 0, "clamp"), lo = num(a, 1, "clamp"), hi = num(a, 2, "clamp"); - return Value.of(Math.max(lo, Math.min(hi, v))); - }); - register("lerp", (a, c) -> { - double lo = num(a, 0, "lerp"), hi = num(a, 1, "lerp"), t = num(a, 2, "lerp"); - return Value.of(lo + (hi - lo) * t); - }); - - // Math — 1-arg - register("abs", (a, c) -> Value.of(Math.abs(num(a, 0, "abs")))); - register("sqrt", (a, c) -> Value.of(Math.sqrt(Math.max(0, num(a, 0, "sqrt"))))); - register("floor", (a, c) -> Value.of(Math.floor(num(a, 0, "floor")))); - register("ceil", (a, c) -> Value.of(Math.ceil(num(a, 0, "ceil")))); - register("round", (a, c) -> Value.of(Math.rint(num(a, 0, "round")))); - register("sign", (a, c) -> Value.of(Math.signum(num(a, 0, "sign")))); - register("sin", (a, c) -> Value.of(Math.sin(num(a, 0, "sin")))); - register("cos", (a, c) -> Value.of(Math.cos(num(a, 0, "cos")))); - register("tan", (a, c) -> Value.of(Math.tan(num(a, 0, "tan")))); - register("asin", (a, c) -> Value.of(Math.asin(num(a, 0, "asin")))); - register("acos", (a, c) -> Value.of(Math.acos(num(a, 0, "acos")))); - register("atan", (a, c) -> Value.of(Math.atan(num(a, 0, "atan")))); - register("exp", (a, c) -> Value.of(Math.exp(num(a, 0, "exp")))); - register("rad", (a, c) -> Value.of(Math.toRadians(num(a, 0, "rad")))); - register("deg", (a, c) -> Value.of(Math.toDegrees(num(a, 0, "deg")))); - - // Control - register("if", (a, c) -> { - requireArity("if", a, 3); - return a.get(0).asBool() ? a.get(1) : a.get(2); - }); - - // String functions - register("str", (a, c) -> { - requireArity("str", a, 1); - return Value.of(a.get(0).asString()); - }); - register("num", (a, c) -> { - requireArity("num", a, 1); - return Value.of(a.get(0).asNumber()); - }); - register("concat", (a, c) -> { - if (a.isEmpty()) throw new IllegalArgumentException("concat requires at least 1 arg"); - StringBuilder sb = new StringBuilder(); - for (Value v : a) sb.append(v.asString()); - return Value.of(sb.toString()); - }); - register("len", (a, c) -> { - requireArity("len", a, 1); - return Value.of(a.get(0).asString().length()); - }); - register("substr", (a, c) -> { - if (a.size() < 2) throw new IllegalArgumentException("substr(str, start[, end])"); - String s = a.get(0).asString(); - int start = (int) a.get(1).asNumber(); - int end = a.size() >= 3 ? (int) a.get(2).asNumber() : s.length(); - start = Math.max(0, Math.min(start, s.length())); - end = Math.max(start, Math.min(end, s.length())); - return Value.of(s.substring(start, end)); - }); - register("upper", (a, c) -> { - requireArity("upper", a, 1); - return Value.of(a.get(0).asString().toUpperCase(Locale.ROOT)); - }); - register("lower", (a, c) -> { - requireArity("lower", a, 1); - return Value.of(a.get(0).asString().toLowerCase(Locale.ROOT)); - }); - register("contains", (a, c) -> { - requireArity("contains", a, 2); - return Value.ofBool(a.get(0).asString().contains(a.get(1).asString())); - }); - register("starts_with", (a, c) -> { - requireArity("starts_with", a, 2); - return Value.ofBool(a.get(0).asString().startsWith(a.get(1).asString())); - }); - register("ends_with", (a, c) -> { - requireArity("ends_with", a, 2); - return Value.ofBool(a.get(0).asString().endsWith(a.get(1).asString())); - }); - register("replace", (a, c) -> { - requireArity("replace", a, 3); - return Value.of(a.get(0).asString().replace(a.get(1).asString(), a.get(2).asString())); - }); - register("format", (a, c) -> { - if (a.size() < 2) throw new IllegalArgumentException("format(fmt, args...)"); - Object[] fmtArgs = new Object[a.size() - 1]; - for (int i = 1; i < a.size(); i++) { - Value v = a.get(i); - fmtArgs[i - 1] = v.isNumber() ? v.asNumber() : v.asString(); - } - return Value.of(String.format(a.get(0).asString(), fmtArgs)); - }); - - // Stateful helpers - register("prev", (a, c) -> { - if (a.isEmpty()) throw new IllegalArgumentException("prev(x[, default])"); - Value current = a.get(0); - Value def = a.size() >= 2 ? a.get(1) : Value.ZERO; - String key = "__prev_" + c.nextCallSiteIndex(); - Value old = c.getState(key, def); - c.setState(key, current); - return old; - }); - register("rising", (a, c) -> { - requireArity("rising", a, 1); - boolean cur = a.get(0).asBool(); - String key = "__rising_" + c.nextCallSiteIndex(); - boolean prev = c.getState(key, Value.ZERO).asBool(); - c.setState(key, Value.ofBool(cur)); - return Value.ofBool(cur && !prev); - }); - register("falling", (a, c) -> { - requireArity("falling", a, 1); - boolean cur = a.get(0).asBool(); - String key = "__falling_" + c.nextCallSiteIndex(); - boolean prev = c.getState(key, Value.ZERO).asBool(); - c.setState(key, Value.ofBool(cur)); - return Value.ofBool(!cur && prev); - }); - register("changed", (a, c) -> { - requireArity("changed", a, 1); - Value cur = a.get(0); - String key = "__changed_" + c.nextCallSiteIndex(); - Value prev = c.getState(key, cur); - c.setState(key, cur); - return Value.ofBool(!cur.equals(prev)); - }); - } - - private static double num(List args, int idx, String name) { - if (idx >= args.size()) throw new IllegalArgumentException(name + ": missing arg " + idx); - return args.get(idx).asNumber(); - } - - private static void requireArity(String name, List args, int expected) { - if (args.size() != expected) - throw new IllegalArgumentException(name + " expects " + expected + " arg(s)"); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/expr/Lexer.java b/src/main/java/dev/propulsionteam/computed/customnodes/expr/Lexer.java deleted file mode 100644 index 22dca42..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/expr/Lexer.java +++ /dev/null @@ -1,113 +0,0 @@ -package dev.propulsionteam.computed.customnodes.expr; - -import java.util.ArrayList; -import java.util.List; - -public final class Lexer { - public enum TokenType { - NUMBER, STRING_LIT, IDENT, - LPAREN, RPAREN, COMMA, SEMICOLON, - PLUS, MINUS, STAR, SLASH, PERCENT, - BANG, AND_AND, OR_OR, - EQ, EQ_EQ, BANG_EQ, LT, LT_EQ, GT, GT_EQ, - EOF - } - - public record Token(TokenType type, String text, double number, String string) { - static Token num(double v, String text) { return new Token(TokenType.NUMBER, text, v, null); } - static Token str(String v) { return new Token(TokenType.STRING_LIT, "\"" + v + "\"", 0, v); } - static Token ident(String t) { return new Token(TokenType.IDENT, t, 0, null); } - static Token sym(TokenType t, String text) { return new Token(t, text, 0, null); } - } - - public static List tokenize(String source) { - List tokens = new ArrayList<>(); - int i = 0; - int len = source.length(); - while (i < len) { - char c = source.charAt(i); - if (Character.isWhitespace(c)) { i++; continue; } - - // Numbers - if (Character.isDigit(c) || (c == '.' && i + 1 < len && Character.isDigit(source.charAt(i + 1)))) { - int start = i++; - while (i < len && (Character.isDigit(source.charAt(i)) || source.charAt(i) == '.')) i++; - String text = source.substring(start, i); - tokens.add(Token.num(Double.parseDouble(text), text)); - continue; - } - - // Identifiers - if (Character.isLetter(c) || c == '_') { - int start = i++; - while (i < len && (Character.isLetterOrDigit(source.charAt(i)) || source.charAt(i) == '_')) i++; - tokens.add(Token.ident(source.substring(start, i))); - continue; - } - - // String literals: "..." or '...' - if (c == '"' || c == '\'') { - char quote = c; - i++; - StringBuilder sb = new StringBuilder(); - while (i < len && source.charAt(i) != quote) { - char ch = source.charAt(i); - if (ch == '\\' && i + 1 < len) { - i++; - char esc = source.charAt(i); - sb.append(switch (esc) { - case 'n' -> '\n'; - case 't' -> '\t'; - case 'r' -> '\r'; - default -> esc; - }); - } else { - sb.append(ch); - } - i++; - } - if (i >= len) throw new IllegalArgumentException("Unterminated string literal"); - i++; // consume closing quote - tokens.add(Token.str(sb.toString())); - continue; - } - - // Two-char operators - if (i + 1 < len) { - String two = source.substring(i, i + 2); - TokenType tt = switch (two) { - case "&&" -> TokenType.AND_AND; - case "||" -> TokenType.OR_OR; - case "==" -> TokenType.EQ_EQ; - case "!=" -> TokenType.BANG_EQ; - case "<=" -> TokenType.LT_EQ; - case ">=" -> TokenType.GT_EQ; - default -> null; - }; - if (tt != null) { tokens.add(Token.sym(tt, two)); i += 2; continue; } - } - - // Single-char - TokenType tt = switch (c) { - case '(' -> TokenType.LPAREN; - case ')' -> TokenType.RPAREN; - case ',' -> TokenType.COMMA; - case ';' -> TokenType.SEMICOLON; - case '+' -> TokenType.PLUS; - case '-' -> TokenType.MINUS; - case '*' -> TokenType.STAR; - case '/' -> TokenType.SLASH; - case '%' -> TokenType.PERCENT; - case '!' -> TokenType.BANG; - case '<' -> TokenType.LT; - case '>' -> TokenType.GT; - case '=' -> TokenType.EQ; - default -> throw new IllegalArgumentException("Unsupported character: " + c); - }; - tokens.add(Token.sym(tt, String.valueOf(c))); - i++; - } - tokens.add(Token.sym(TokenType.EOF, "")); - return tokens; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/expr/Parser.java b/src/main/java/dev/propulsionteam/computed/customnodes/expr/Parser.java deleted file mode 100644 index 6413b6d..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/expr/Parser.java +++ /dev/null @@ -1,173 +0,0 @@ -package dev.propulsionteam.computed.customnodes.expr; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -public final class Parser { - private final List tokens; - private final EvalContext ctx; - private int index = 0; - - public Parser(List tokens, EvalContext ctx) { - this.tokens = tokens; - this.ctx = ctx; - } - - /** Parse and evaluate a program: one or more `;`-separated statements. - * The value of the last statement is returned. */ - public Value parseProgram() { - Value result = Value.ZERO; - while (!check(Lexer.TokenType.EOF)) { - result = parseStatement(); - if (!check(Lexer.TokenType.EOF)) { - expect(Lexer.TokenType.SEMICOLON); - } - } - return result; - } - - /** A statement is either an assignment (`name = expr`) or a bare expression. */ - private Value parseStatement() { - // Lookahead: IDENT followed by `=` (but not `==`) → assignment - if (check(Lexer.TokenType.IDENT) && peekAhead(1) == Lexer.TokenType.EQ) { - String name = tokens.get(index).text().toLowerCase(Locale.ROOT); - index += 2; // consume ident + `=` - Value rhs = parseExpression(); - ctx.setVar(name, rhs); - return rhs; - } - return parseExpression(); - } - - private Value parseExpression() { return parseOr(); } - - private Value parseOr() { - Value left = parseAnd(); - while (match(Lexer.TokenType.OR_OR)) { - Value right = parseAnd(); - left = Value.ofBool(left.asBool() || right.asBool()); - } - return left; - } - - private Value parseAnd() { - Value left = parseEquality(); - while (match(Lexer.TokenType.AND_AND)) { - Value right = parseEquality(); - left = Value.ofBool(left.asBool() && right.asBool()); - } - return left; - } - - private Value parseEquality() { - Value left = parseComparison(); - while (true) { - if (match(Lexer.TokenType.EQ_EQ)) { - left = Value.ofBool(left.equals(parseComparison())); - } else if (match(Lexer.TokenType.BANG_EQ)) { - left = Value.ofBool(!left.equals(parseComparison())); - } else return left; - } - } - - private Value parseComparison() { - Value left = parseTerm(); - while (true) { - if (match(Lexer.TokenType.LT)) { left = Value.ofBool(left.asNumber() < parseTerm().asNumber()); } - else if (match(Lexer.TokenType.LT_EQ)) { left = Value.ofBool(left.asNumber() <= parseTerm().asNumber()); } - else if (match(Lexer.TokenType.GT)) { left = Value.ofBool(left.asNumber() > parseTerm().asNumber()); } - else if (match(Lexer.TokenType.GT_EQ)) { left = Value.ofBool(left.asNumber() >= parseTerm().asNumber()); } - else return left; - } - } - - private Value parseTerm() { - Value left = parseFactor(); - while (true) { - if (match(Lexer.TokenType.PLUS)) { left = left.add(parseFactor()); } - else if (match(Lexer.TokenType.MINUS)) { left = Value.of(left.asNumber() - parseFactor().asNumber()); } - else return left; - } - } - - private Value parseFactor() { - Value left = parseUnary(); - while (true) { - if (match(Lexer.TokenType.STAR)) { - left = Value.of(left.asNumber() * parseUnary().asNumber()); - } else if (match(Lexer.TokenType.SLASH)) { - double r = parseUnary().asNumber(); - left = Value.of(r == 0.0 ? 0.0 : left.asNumber() / r); - } else if (match(Lexer.TokenType.PERCENT)) { - double r = parseUnary().asNumber(); - left = Value.of(r == 0.0 ? 0.0 : left.asNumber() % r); - } else return left; - } - } - - private Value parseUnary() { - if (match(Lexer.TokenType.MINUS)) return Value.of(-parseUnary().asNumber()); - if (match(Lexer.TokenType.PLUS)) return parseUnary(); - if (match(Lexer.TokenType.BANG)) return Value.ofBool(!parseUnary().asBool()); - return parsePrimary(); - } - - private Value parsePrimary() { - Lexer.Token tok = peek(); - - if (match(Lexer.TokenType.NUMBER)) { - return Value.of(tok.number()); - } - - if (match(Lexer.TokenType.STRING_LIT)) { - return Value.of(tok.string()); - } - - if (match(Lexer.TokenType.IDENT)) { - String name = tok.text().toLowerCase(Locale.ROOT); - if (match(Lexer.TokenType.LPAREN)) { - List args = new ArrayList<>(); - if (!check(Lexer.TokenType.RPAREN)) { - do { args.add(parseExpression()); } while (match(Lexer.TokenType.COMMA)); - } - expect(Lexer.TokenType.RPAREN); - return ctx.callFunction(name, args); - } - // Variable lookup - if (ctx.hasVar(name)) return ctx.getVar(name); - throw new IllegalArgumentException("Unknown variable: " + tok.text()); - } - - if (match(Lexer.TokenType.LPAREN)) { - Value v = parseExpression(); - expect(Lexer.TokenType.RPAREN); - return v; - } - - throw new IllegalArgumentException("Unexpected token: " + tok.text()); - } - - // --- Helpers --- - - private boolean match(Lexer.TokenType type) { - if (check(type)) { index++; return true; } - return false; - } - - private boolean check(Lexer.TokenType type) { - return peek().type() == type; - } - - private Lexer.Token peek() { return tokens.get(index); } - - private Lexer.TokenType peekAhead(int offset) { - int i = index + offset; - return i < tokens.size() ? tokens.get(i).type() : Lexer.TokenType.EOF; - } - - private void expect(Lexer.TokenType type) { - if (!match(type)) - throw new IllegalArgumentException("Expected " + type + " but got " + peek().type()); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/expr/Value.java b/src/main/java/dev/propulsionteam/computed/customnodes/expr/Value.java deleted file mode 100644 index 93b8733..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/expr/Value.java +++ /dev/null @@ -1,86 +0,0 @@ -package dev.propulsionteam.computed.customnodes.expr; - -public final class Value { - public enum Type { NUMBER, STRING } - - public static final Value ZERO = new Value(0.0); - public static final Value ONE = new Value(1.0); - public static final Value EMPTY_STRING = new Value(""); - - private final Type type; - private final double number; - private final String string; - - private Value(double number) { - this.type = Type.NUMBER; - this.number = number; - this.string = null; - } - - private Value(String string) { - this.type = Type.STRING; - this.number = 0.0; - this.string = string; - } - - public static Value of(double d) { - if (d == 0.0) return ZERO; - if (d == 1.0) return ONE; - return new Value(d); - } - - public static Value of(String s) { - if (s == null || s.isEmpty()) return EMPTY_STRING; - return new Value(s); - } - - public static Value ofBool(boolean b) { - return b ? ONE : ZERO; - } - - public Type type() { return type; } - public boolean isNumber() { return type == Type.NUMBER; } - public boolean isString() { return type == Type.STRING; } - - public double asNumber() { - if (type == Type.NUMBER) return number; - try { return Double.parseDouble(string); } catch (NumberFormatException e) { return 0.0; } - } - - public String asString() { - if (type == Type.STRING) return string; - if (number == Math.floor(number) && !Double.isInfinite(number)) { - return String.valueOf((long) number); - } - return String.valueOf(number); - } - - public boolean asBool() { - return type == Type.NUMBER ? number > 0.5 : !string.isEmpty(); - } - - /** Add: string concat when either side is a string, else numeric add. */ - public Value add(Value other) { - if (type == Type.STRING || other.type == Type.STRING) { - return Value.of(this.asString() + other.asString()); - } - return Value.of(this.number + other.number); - } - - @Override - public String toString() { - return type == Type.NUMBER ? asString() : "\"" + string + "\""; - } - - @Override - public boolean equals(Object obj) { - if (!(obj instanceof Value v)) return false; - if (type != v.type) return false; - return type == Type.NUMBER ? Double.compare(number, v.number) == 0 : string.equals(v.string); - } - - @Override - public int hashCode() { - return type == Type.NUMBER ? Double.hashCode(number) : string.hashCode(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/sources/CreateSources.java b/src/main/java/dev/propulsionteam/computed/customnodes/sources/CreateSources.java deleted file mode 100644 index 01bb02f..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/sources/CreateSources.java +++ /dev/null @@ -1,60 +0,0 @@ -package dev.propulsionteam.computed.customnodes.sources; - -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlock; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import dev.propulsionteam.computed.content.nodes.vanilla.RelativeFace; -import dev.propulsionteam.computed.customnodes.expr.FunctionRegistry; -import dev.propulsionteam.computed.customnodes.expr.Value; -import dev.propulsionteam.computed.integration.CreateKineticBridge; -import java.util.List; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.state.BlockState; - -public final class CreateSources { - private CreateSources() {} - - public static void register(FunctionRegistry reg) { - reg.register("create_kinetic", (args, ctx) -> { - BlockPos pos = neighborPos(args); if (pos == null) return Value.ZERO; - Level lvl = hostLevel(); if (lvl == null) return Value.ZERO; - return Value.ofBool(CreateKineticBridge.isKinetic(lvl, pos)); - }); - reg.register("create_speed", (args, ctx) -> { - BlockPos pos = neighborPos(args); if (pos == null) return Value.ZERO; - Level lvl = hostLevel(); if (lvl == null) return Value.ZERO; - return Value.of(CreateKineticBridge.getSpeed(lvl, pos)); - }); - reg.register("create_stress", (args, ctx) -> { - BlockPos pos = neighborPos(args); if (pos == null) return Value.ZERO; - Level lvl = hostLevel(); if (lvl == null) return Value.ZERO; - return Value.of(CreateKineticBridge.getStress(lvl, pos)); - }); - reg.register("create_capacity", (args, ctx) -> { - BlockPos pos = neighborPos(args); if (pos == null) return Value.ZERO; - Level lvl = hostLevel(); if (lvl == null) return Value.ZERO; - return Value.of(CreateKineticBridge.getCapacity(lvl, pos)); - }); - } - - private static Level hostLevel() { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return null; - Level lvl = host.getLevel(); - return (lvl == null || lvl.isClientSide) ? null : lvl; - } - - private static BlockPos neighborPos(List args) { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return null; - BlockState selfState = host.getBlockState(); - if (!selfState.hasProperty(ComputerBlock.FACING)) return null; - Direction facing = selfState.getValue(ComputerBlock.FACING); - String faceName = args.isEmpty() ? "front" : args.get(0).asString(); - RelativeFace rel = RelativeFace.byName(faceName); - if (rel == null) rel = RelativeFace.FRONT; - return host.getBlockPos().relative(rel.toWorld(facing)); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/customnodes/sources/WorldSources.java b/src/main/java/dev/propulsionteam/computed/customnodes/sources/WorldSources.java deleted file mode 100644 index 2b90224..0000000 --- a/src/main/java/dev/propulsionteam/computed/customnodes/sources/WorldSources.java +++ /dev/null @@ -1,196 +0,0 @@ -package dev.propulsionteam.computed.customnodes.sources; - -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlock; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import dev.propulsionteam.computed.customnodes.expr.FunctionRegistry; -import dev.propulsionteam.computed.customnodes.expr.Value; -import java.util.List; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.core.Holder; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.LightLayer; -import net.minecraft.world.level.biome.Biome; -import net.minecraft.world.level.block.Block; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraft.world.level.material.FluidState; -import net.minecraft.world.level.material.Fluids; -import net.neoforged.neoforge.capabilities.Capabilities; -import net.neoforged.neoforge.items.IItemHandler; - -import dev.propulsionteam.computed.content.nodes.vanilla.RelativeFace; - -public final class WorldSources { - private WorldSources() {} - - public static void register(FunctionRegistry reg) { - // --- Environment --- - reg.register("light_level", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.ZERO; - int sky = wc.level.getBrightness(LightLayer.SKY, wc.pos); - int block = wc.level.getBrightness(LightLayer.BLOCK, wc.pos); - return Value.of(Math.max(sky, block)); - }); - reg.register("light_sky", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.ZERO; - return Value.of(wc.level.getBrightness(LightLayer.SKY, wc.pos)); - }); - reg.register("light_block", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.ZERO; - return Value.of(wc.level.getBrightness(LightLayer.BLOCK, wc.pos)); - }); - reg.register("is_raining", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.ZERO; - return Value.ofBool(wc.level.isRaining()); - }); - reg.register("is_thundering", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.ZERO; - return Value.ofBool(wc.level.isThundering()); - }); - reg.register("is_day", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.ZERO; - return Value.ofBool(wc.level.isDay()); - }); - reg.register("biome_temp", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.ZERO; - return Value.of(wc.level.getBiome(wc.pos).value().getBaseTemperature()); - }); - reg.register("biome_downfall", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.ZERO; - return Value.of(wc.level.getBiome(wc.pos).value().getModifiedClimateSettings().downfall()); - }); - reg.register("biome_name", (args, ctx) -> { - Context wc = worldCtx(); if (wc == null) return Value.EMPTY_STRING; - Holder holder = wc.level.getBiome(wc.pos); - String name = holder.unwrapKey() - .map(k -> k.location().toString()) - .orElse("unknown"); - return Value.of(name); - }); - - // --- Block --- - reg.register("block_id", (args, ctx) -> { - Context wc = neighborCtx(args); if (wc == null) return Value.EMPTY_STRING; - Block block = wc.level.getBlockState(wc.pos).getBlock(); - return Value.of(BuiltInRegistries.BLOCK.getKey(block).toString()); - }); - reg.register("block_is", (args, ctx) -> { - if (args.isEmpty()) return Value.ZERO; - Context wc = neighborCtx(args.subList(1, args.size())); if (wc == null) return Value.ZERO; - Block block = wc.level.getBlockState(wc.pos).getBlock(); - String id = BuiltInRegistries.BLOCK.getKey(block).toString(); - return Value.ofBool(id.equals(args.get(0).asString())); - }); - - // --- Fluid --- - reg.register("fluid_present", (args, ctx) -> { - Context wc = neighborCtx(args); if (wc == null) return Value.ZERO; - return Value.ofBool(!wc.level.getFluidState(wc.pos).isEmpty()); - }); - reg.register("fluid_level", (args, ctx) -> { - Context wc = neighborCtx(args); if (wc == null) return Value.ZERO; - FluidState fs = wc.level.getFluidState(wc.pos); - return Value.of(fs.isEmpty() ? 0 : fs.getAmount()); - }); - reg.register("fluid_type", (args, ctx) -> { - Context wc = neighborCtx(args); if (wc == null) return Value.EMPTY_STRING; - FluidState fs = wc.level.getFluidState(wc.pos); - if (fs.isEmpty()) return Value.EMPTY_STRING; - if (fs.getType() == Fluids.WATER || fs.getType() == Fluids.FLOWING_WATER) return Value.of("water"); - if (fs.getType() == Fluids.LAVA || fs.getType() == Fluids.FLOWING_LAVA) return Value.of("lava"); - return Value.of("unknown"); - }); - - // --- Inventory / container --- - reg.register("container_slots", (args, ctx) -> { - CapCtx cc = itemHandler(args); if (cc == null) return Value.ZERO; - return Value.of(cc.cap.getSlots()); - }); - reg.register("container_count", (args, ctx) -> { - CapCtx cc = itemHandler(args); if (cc == null) return Value.ZERO; - int total = 0; - for (int i = 0; i < cc.cap.getSlots(); i++) total += cc.cap.getStackInSlot(i).getCount(); - return Value.of(total); - }); - reg.register("container_fill", (args, ctx) -> { - CapCtx cc = itemHandler(args); if (cc == null) return Value.ZERO; - int slots = cc.cap.getSlots(); - if (slots == 0) return Value.ZERO; - double used = 0, capacity = 0; - for (int i = 0; i < slots; i++) { - var stack = cc.cap.getStackInSlot(i); - int limit = cc.cap.getSlotLimit(i); - used += stack.getCount(); - capacity += limit > 0 ? limit : 64; - } - return Value.of(capacity > 0 ? used / capacity : 0.0); - }); - reg.register("comparator", (args, ctx) -> { - Context wc = neighborCtx(args); if (wc == null) return Value.ZERO; - BlockState target = wc.level.getBlockState(wc.pos); - if (target.hasAnalogOutputSignal()) { - return Value.of(target.getAnalogOutputSignal(wc.level, wc.pos)); - } - // Fallback to weak redstone from that face - Direction face = neighborDir(args); - return Value.of(face != null ? wc.level.getSignal(wc.pos, face) : 0); - }); - } - - // --- Internal helpers --- - - private record Context(Level level, BlockPos pos) {} - private record CapCtx(T cap, Context wc) {} - - /** Context for queries at the computer's own position. */ - private static Context worldCtx() { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return null; - Level lvl = host.getLevel(); - if (lvl == null || lvl.isClientSide) return null; - return new Context(lvl, host.getBlockPos()); - } - - /** Context for queries at a neighboring block; face arg is first arg (string). */ - private static Context neighborCtx(List args) { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return null; - Level lvl = host.getLevel(); - if (lvl == null || lvl.isClientSide) return null; - Direction dir = resolveDir(host, args); - if (dir == null) return null; - return new Context(lvl, host.getBlockPos().relative(dir)); - } - - /** Resolved world Direction for the face arg on a neighbor query. */ - private static Direction neighborDir(List args) { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return null; - return resolveDir(host, args); - } - - private static Direction resolveDir(ComputerBlockEntity host, List args) { - BlockState selfState = host.getBlockState(); - if (!selfState.hasProperty(ComputerBlock.FACING)) return null; - Direction facing = selfState.getValue(ComputerBlock.FACING); - String faceName = args.isEmpty() ? "front" : args.get(0).asString(); - RelativeFace rel = RelativeFace.byName(faceName); - if (rel == null) rel = RelativeFace.FRONT; - return rel.toWorld(facing); - } - - private static CapCtx itemHandler(List args) { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) return null; - Level lvl = host.getLevel(); - if (lvl == null || lvl.isClientSide) return null; - Direction dir = resolveDir(host, args); - if (dir == null) return null; - BlockPos neighbor = host.getBlockPos().relative(dir); - IItemHandler handler = lvl.getCapability(Capabilities.ItemHandler.BLOCK, neighbor, dir.getOpposite()); - if (handler == null) return null; - return new CapCtx<>(handler, new Context(lvl, neighbor)); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/diagnostics/ComputedDiagnostic.java b/src/main/java/dev/propulsionteam/computed/diagnostics/ComputedDiagnostic.java new file mode 100644 index 0000000..494eb07 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/diagnostics/ComputedDiagnostic.java @@ -0,0 +1,37 @@ +package dev.propulsionteam.computed.diagnostics; + +import java.util.Objects; +import java.util.UUID; + +public record ComputedDiagnostic( + Severity severity, + Phase phase, + String code, + String message, + UUID nodeId, + Integer line, + Integer column) { + + public ComputedDiagnostic { + Objects.requireNonNull(severity, "severity"); + Objects.requireNonNull(phase, "phase"); + code = code == null || code.isBlank() ? "unknown" : code; + message = message == null ? "" : message; + } + + public enum Severity { + INFO, + WARNING, + ERROR + } + + public enum Phase { + COMPILE, + DEFINITION, + GRAPH, + RUNTIME, + ENDPOINT, + PERSISTENCE, + PREVIEW + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/ComputedGraph.java b/src/main/java/dev/propulsionteam/computed/graph/ComputedGraph.java new file mode 100644 index 0000000..25c305c --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/ComputedGraph.java @@ -0,0 +1,18 @@ +package dev.propulsionteam.computed.graph; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public record ComputedGraph(UUID id, List nodes, List connections) { + public ComputedGraph { + Objects.requireNonNull(id, "id"); + nodes = nodes == null ? List.of() : List.copyOf(nodes); + connections = connections == null ? List.of() : List.copyOf(connections); + } + + public Optional node(UUID id) { + return nodes.stream().filter(node -> node.id().equals(id)).findFirst(); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/ComputedProgramV3.java b/src/main/java/dev/propulsionteam/computed/graph/ComputedProgramV3.java new file mode 100644 index 0000000..92d9c88 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/ComputedProgramV3.java @@ -0,0 +1,77 @@ +package dev.propulsionteam.computed.graph; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import net.minecraft.nbt.CompoundTag; + +public record ComputedProgramV3( + long revision, + ComputedGraph rootGraph, + Map library, + Map persistentState, + CompoundTag metadata) { + + public static final int FORMAT_VERSION = 3; + public static final int MAX_EMBEDDED_DEFINITIONS = 256; + + public ComputedProgramV3 { + revision = Math.max(0, revision); + Objects.requireNonNull(rootGraph, "rootGraph"); + library = copyLibrary(library); + persistentState = copyState(persistentState); + metadata = metadata == null ? new CompoundTag() : metadata.copy(); + long embeddedCount = library.values().stream() + .filter(source -> source.origin() == LuaDefinitionSource.Origin.EMBEDDED) + .count(); + if (embeddedCount > MAX_EMBEDDED_DEFINITIONS) { + throw new IllegalArgumentException( + "Program exceeds the embedded definition limit of " + MAX_EMBEDDED_DEFINITIONS); + } + } + + public static ComputedProgramV3 empty(UUID graphId) { + return new ComputedProgramV3( + 0, + new ComputedGraph(graphId, java.util.List.of(), java.util.List.of()), + Map.of(), + Map.of(), + new CompoundTag()); + } + + @Override + public Map persistentState() { + return copyState(persistentState); + } + + @Override + public CompoundTag metadata() { + return metadata.copy(); + } + + public ComputedProgramV3 withRevision(long revision) { + return new ComputedProgramV3(revision, rootGraph, library, persistentState, metadata); + } + + private static Map copyLibrary(Map source) { + Map copy = new LinkedHashMap<>(); + if (source != null) { + source.forEach((id, definition) -> { + if (!id.equals(definition.id())) { + throw new IllegalArgumentException("Lua definition library key does not match definition id"); + } + copy.put(id, definition); + }); + } + return java.util.Collections.unmodifiableMap(copy); + } + + private static Map copyState(Map source) { + Map copy = new LinkedHashMap<>(); + if (source != null) { + source.forEach((id, state) -> copy.put(id, state.copy())); + } + return java.util.Collections.unmodifiableMap(copy); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/GraphAnalysisResult.java b/src/main/java/dev/propulsionteam/computed/graph/GraphAnalysisResult.java new file mode 100644 index 0000000..1d4ec34 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/GraphAnalysisResult.java @@ -0,0 +1,24 @@ +package dev.propulsionteam.computed.graph; + +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic; +import java.util.List; +import java.util.UUID; + +public record GraphAnalysisResult( + List executionOrder, + List> combinationalCycles, + List diagnostics) { + + public GraphAnalysisResult { + executionOrder = executionOrder == null ? List.of() : List.copyOf(executionOrder); + combinationalCycles = combinationalCycles == null + ? List.of() + : combinationalCycles.stream().map(List::copyOf).toList(); + diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); + } + + public boolean valid() { + return diagnostics.stream() + .noneMatch(diagnostic -> diagnostic.severity() == ComputedDiagnostic.Severity.ERROR); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/GraphAnalyzer.java b/src/main/java/dev/propulsionteam/computed/graph/GraphAnalyzer.java new file mode 100644 index 0000000..ea887cc --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/GraphAnalyzer.java @@ -0,0 +1,189 @@ +package dev.propulsionteam.computed.graph; + +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic; +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Phase; +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Severity; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; + +public final class GraphAnalyzer { + private static final Comparator UUID_ORDER = Comparator.comparing(UUID::toString); + + private GraphAnalyzer() {} + + public static GraphAnalysisResult analyze( + ComputedGraph graph, + Predicate stateBoundary) { + Map nodes = new LinkedHashMap<>(); + List diagnostics = new ArrayList<>(); + for (GraphNode node : graph.nodes()) { + if (nodes.putIfAbsent(node.id(), node) != null) { + diagnostics.add(error("duplicate_node", "Duplicate node id " + node.id(), node.id())); + } + } + Map> outgoing = new HashMap<>(); + Map indegree = new HashMap<>(); + nodes.keySet().forEach(id -> { + outgoing.put(id, new LinkedHashSet<>()); + indegree.put(id, 0); + }); + Set occupiedInputs = new HashSet<>(); + for (GraphConnection connection : graph.connections()) { + GraphNode source = nodes.get(connection.sourceNode()); + GraphNode target = nodes.get(connection.targetNode()); + if (source == null || target == null) { + diagnostics.add(new ComputedDiagnostic( + Severity.ERROR, + Phase.GRAPH, + "dangling_connection", + "Connection references a missing node", + null, + null, + null)); + continue; + } + PortSnapshot sourcePort = findPort(source, connection.sourcePort(), PortDirection.OUTPUT); + PortSnapshot targetPort = findPort(target, connection.targetPort(), PortDirection.INPUT); + if (sourcePort == null || targetPort == null) { + diagnostics.add(error( + "missing_port", + "Connection references a missing port", + target.id())); + continue; + } + if (sourcePort.type() != targetPort.type()) { + diagnostics.add(error( + "incompatible_ports", + "Cannot connect " + sourcePort.type() + " to " + targetPort.type(), + target.id())); + continue; + } + String inputIdentity = target.id() + "\u0000" + targetPort.id(); + if (!occupiedInputs.add(inputIdentity)) { + diagnostics.add(error( + "multiple_input_connections", + "Input " + targetPort.id() + " has more than one connection", + target.id())); + continue; + } + if (!stateBoundary.test(target) && outgoing.get(source.id()).add(target.id())) { + indegree.compute(target.id(), (id, value) -> value + 1); + } + } + + PriorityQueue ready = new PriorityQueue<>(UUID_ORDER); + indegree.forEach((id, degree) -> { + if (degree == 0) { + ready.add(id); + } + }); + List order = new ArrayList<>(nodes.size()); + while (!ready.isEmpty()) { + UUID node = ready.remove(); + order.add(node); + outgoing.get(node).stream().sorted(UUID_ORDER).forEach(target -> { + int next = indegree.compute(target, (id, degree) -> degree - 1); + if (next == 0) { + ready.add(target); + } + }); + } + + Set unresolved = new HashSet<>(nodes.keySet()); + unresolved.removeAll(order); + List> cycles = stronglyConnected(outgoing, unresolved); + cycles.forEach(cycle -> diagnostics.add(error( + "combinational_cycle", + "Combinational cycle contains " + cycle.size() + " nodes", + cycle.getFirst()))); + unresolved.stream().sorted(UUID_ORDER).forEach(order::add); + return new GraphAnalysisResult(order, cycles, diagnostics); + } + + private static PortSnapshot findPort(GraphNode node, String id, PortDirection direction) { + return node.ports().stream() + .filter(port -> port.direction() == direction && port.id().equals(id)) + .findFirst() + .orElse(null); + } + + private static List> stronglyConnected( + Map> outgoing, + Set candidates) { + Tarjan tarjan = new Tarjan(outgoing, candidates); + candidates.stream().sorted(UUID_ORDER).forEach(tarjan::visitIfNeeded); + return tarjan.cycles.stream() + .sorted(Comparator.comparing(cycle -> cycle.getFirst().toString())) + .toList(); + } + + private static ComputedDiagnostic error(String code, String message, UUID nodeId) { + return new ComputedDiagnostic(Severity.ERROR, Phase.GRAPH, code, message, nodeId, null, null); + } + + private static final class Tarjan { + private final Map> outgoing; + private final Set candidates; + private final Map indices = new HashMap<>(); + private final Map lowLinks = new HashMap<>(); + private final ArrayDeque stack = new ArrayDeque<>(); + private final Set stacked = new HashSet<>(); + private final List> cycles = new ArrayList<>(); + private int index; + + private Tarjan(Map> outgoing, Set candidates) { + this.outgoing = outgoing; + this.candidates = candidates; + } + + private void visitIfNeeded(UUID node) { + if (!indices.containsKey(node)) { + visit(node); + } + } + + private void visit(UUID node) { + indices.put(node, index); + lowLinks.put(node, index++); + stack.push(node); + stacked.add(node); + outgoing.get(node).stream() + .filter(candidates::contains) + .sorted(UUID_ORDER) + .forEach(target -> { + if (!indices.containsKey(target)) { + visit(target); + lowLinks.put(node, Math.min(lowLinks.get(node), lowLinks.get(target))); + } else if (stacked.contains(target)) { + lowLinks.put(node, Math.min(lowLinks.get(node), indices.get(target))); + } + }); + if (!lowLinks.get(node).equals(indices.get(node))) { + return; + } + List component = new ArrayList<>(); + UUID current; + do { + current = stack.pop(); + stacked.remove(current); + component.add(current); + } while (!current.equals(node)); + component.sort(UUID_ORDER); + boolean selfLoop = component.size() == 1 && outgoing.get(component.getFirst()).contains(component.getFirst()); + if (component.size() > 1 || selfLoop) { + cycles.add(List.copyOf(component)); + } + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/GraphConnection.java b/src/main/java/dev/propulsionteam/computed/graph/GraphConnection.java new file mode 100644 index 0000000..28b8229 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/GraphConnection.java @@ -0,0 +1,30 @@ +package dev.propulsionteam.computed.graph; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public record GraphConnection( + UUID id, + UUID sourceNode, + String sourcePort, + UUID targetNode, + String targetPort, + List waypoints) { + + public GraphConnection { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(sourceNode, "sourceNode"); + Objects.requireNonNull(targetNode, "targetNode"); + sourcePort = requirePort(sourcePort); + targetPort = requirePort(targetPort); + waypoints = waypoints == null ? List.of() : List.copyOf(waypoints); + } + + private static String requirePort(String id) { + if (id == null || id.isBlank() || id.length() > 64) { + throw new IllegalArgumentException("Invalid connection port id: " + id); + } + return id; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/GraphNode.java b/src/main/java/dev/propulsionteam/computed/graph/GraphNode.java new file mode 100644 index 0000000..f3c1e13 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/GraphNode.java @@ -0,0 +1,39 @@ +package dev.propulsionteam.computed.graph; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import net.minecraft.nbt.CompoundTag; + +public record GraphNode( + UUID id, + String definitionId, + String definitionHash, + int x, + int y, + List ports, + Map fields) { + + public GraphNode { + Objects.requireNonNull(id, "id"); + if (definitionId == null || definitionId.isBlank()) { + throw new IllegalArgumentException("definitionId is required"); + } + definitionHash = definitionHash == null ? "" : definitionHash; + ports = ports == null ? List.of() : List.copyOf(ports); + Map copiedFields = new LinkedHashMap<>(); + if (fields != null) { + fields.forEach((key, value) -> copiedFields.put(key, value.copy())); + } + fields = java.util.Collections.unmodifiableMap(copiedFields); + } + + @Override + public Map fields() { + Map copied = new LinkedHashMap<>(); + fields.forEach((key, value) -> copied.put(key, value.copy())); + return java.util.Collections.unmodifiableMap(copied); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/GraphPoint.java b/src/main/java/dev/propulsionteam/computed/graph/GraphPoint.java new file mode 100644 index 0000000..d4501ce --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/GraphPoint.java @@ -0,0 +1,3 @@ +package dev.propulsionteam.computed.graph; + +public record GraphPoint(double x, double y) {} diff --git a/src/main/java/dev/propulsionteam/computed/graph/LuaDefinitionSource.java b/src/main/java/dev/propulsionteam/computed/graph/LuaDefinitionSource.java new file mode 100644 index 0000000..7f50048 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/LuaDefinitionSource.java @@ -0,0 +1,49 @@ +package dev.propulsionteam.computed.graph; + +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public record LuaDefinitionSource(int apiVersion, String id, String source, String hash, Origin origin) { + public LuaDefinitionSource { + if (apiVersion < 1) { + throw new IllegalArgumentException("apiVersion must be positive"); + } + if (id == null || id.isBlank() || id.length() > 128) { + throw new IllegalArgumentException("Invalid Lua definition id: " + id); + } + source = source == null ? "" : source; + if (source.getBytes(StandardCharsets.UTF_8).length > LuaSourceCompiler.MAX_SOURCE_BYTES) { + throw new IllegalArgumentException("Lua definition source exceeds 64 KiB"); + } + String actualHash = hash(source); + if (hash == null || hash.isBlank()) { + hash = actualHash; + } else if (!hash.equals(actualHash)) { + throw new IllegalArgumentException("Lua definition hash does not match its source"); + } + origin = origin == null ? Origin.EMBEDDED : origin; + } + + public static LuaDefinitionSource embedded(int apiVersion, String id, String source) { + return new LuaDefinitionSource(apiVersion, id, source, "", Origin.EMBEDDED); + } + + private static String hash(String source) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(source.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + public enum Origin { + BUNDLED, + INTEGRATION, + EMBEDDED + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/LuaGraphScheduler.java b/src/main/java/dev/propulsionteam/computed/graph/LuaGraphScheduler.java new file mode 100644 index 0000000..14897ff --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/LuaGraphScheduler.java @@ -0,0 +1,361 @@ +package dev.propulsionteam.computed.graph; + +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic; +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Phase; +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Severity; +import dev.propulsionteam.computed.lua.node.BundledLuaLibrary; +import dev.propulsionteam.computed.lua.node.IntegrationLuaLibrary; +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpoints; +import dev.propulsionteam.computed.lua.node.LuaExecutionPolicy; +import dev.propulsionteam.computed.lua.node.LuaFieldSchema; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.node.LuaPortSchema; +import dev.propulsionteam.computed.lua.runtime.LuaComputerRuntime; +import dev.propulsionteam.computed.lua.runtime.LuaInvocationResult; +import dev.propulsionteam.computed.lua.runtime.LuaNodeInstance; +import dev.propulsionteam.computed.lua.runtime.LuaNodeStatus; +import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import dev.propulsionteam.computed.lua.runtime.LuaValueCopies; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +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; +import net.minecraft.nbt.CompoundTag; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +public final class LuaGraphScheduler { + private final ComputedProgramV3 program; + private final LuaComputerRuntime runtime; + private final LuaStateCodec stateCodec = new LuaStateCodec(); + private final Map instances = new LinkedHashMap<>(); + private final Map nodes = new LinkedHashMap<>(); + private final Map> incoming = new HashMap<>(); + private final Map> lastInputs = new HashMap<>(); + private final Map> resolvedFields = new HashMap<>(); + private final Map> outputs = new LinkedHashMap<>(); + private final List definitionDiagnostics = new ArrayList<>(); + private final ArrayDeque events = new ArrayDeque<>(); + private final GraphAnalysisResult analysis; + private long tick; + private boolean stepRequested; + + public LuaGraphScheduler( + ComputedProgramV3 program, + UUID computerId, + Object endpointHost) { + this.program = Objects.requireNonNull(program, "program"); + runtime = new LuaComputerRuntime( + Objects.requireNonNull(computerId, "computerId"), + new dev.propulsionteam.computed.lua.sandbox.LuaInstructionBudget(), + endpointHost); + BuiltinEndpoints.register(); + program.rootGraph().nodes().forEach(node -> { + nodes.put(node.id(), node); + incoming.put(node.id(), new ArrayList<>()); + }); + program.rootGraph().connections().forEach(connection -> { + List edges = incoming.get(connection.targetNode()); + if (edges != null) { + edges.add(connection); + } + }); + instantiateNodes(); + analysis = GraphAnalyzer.analyze( + program.rootGraph(), + node -> { + LuaNodeInstance instance = instances.get(node.id()); + return instance != null && !instance.definition().stateDefaults().isEmpty(); + }); + } + + public LuaGraphTickResult tick(boolean preview) { + tick++; + runtime.beginTick(tick); + List diagnostics = new ArrayList<>(definitionDiagnostics); + diagnostics.addAll(analysis.diagnostics()); + resumeYielded(diagnostics); + Set cyclicNodes = new HashSet<>(); + analysis.combinationalCycles().forEach(cyclicNodes::addAll); + for (UUID nodeId : analysis.executionOrder()) { + if (cyclicNodes.contains(nodeId)) { + continue; + } + LuaNodeInstance instance = instances.get(nodeId); + GraphNode node = nodes.get(nodeId); + if (instance == null || node == null || instance.status() == LuaNodeStatus.YIELDED) { + continue; + } + Map inputs = inputs(instance.definition(), node); + if (!shouldRun(instance.definition().executionPolicy(), nodeId, inputs)) { + continue; + } + LuaInvocationResult result = instance.run( + inputs, + fields(instance.definition(), node), + tick, + runtime.nextGraphStep(), + preview, + (name, values) -> events.addLast(new GraphEvent(null, name, values))); + outputs.put(nodeId, result.outputs()); + diagnostics.addAll(result.diagnostics()); + if (instance.definition().executionPolicy() == LuaExecutionPolicy.INPUT) { + lastInputs.put(nodeId, LuaValueCopies.copyMap(inputs)); + } + } + dispatchEvents(preview, diagnostics); + stepRequested = false; + return new LuaGraphTickResult(outputs, diagnostics, runtime.graphStep()); + } + + public void requestStep() { + stepRequested = true; + } + + public void emit(String eventName, LuaValue... arguments) { + events.addLast(new GraphEvent(null, eventName, List.of(arguments))); + } + + public boolean eventNode(UUID nodeId, String eventName, LuaValue... arguments) { + LuaNodeInstance instance = instances.get(nodeId); + if (instance == null || !instance.definition().eventHandlers().containsKey(eventName)) { + return false; + } + events.addLast(new GraphEvent(nodeId, eventName, List.of(arguments))); + return true; + } + + public ComputedProgramV3 snapshot(long revision) { + Map persistentState = new LinkedHashMap<>(); + instances.forEach((nodeId, instance) -> persistentState.put( + nodeId, + stateCodec.encode(toTable(instance.state())))); + return new ComputedProgramV3( + revision, + program.rootGraph(), + program.library(), + persistentState, + program.metadata()); + } + + public GraphAnalysisResult analysis() { + return analysis; + } + + public List validationDiagnostics() { + List diagnostics = new ArrayList<>(definitionDiagnostics); + diagnostics.addAll(analysis.diagnostics()); + return List.copyOf(diagnostics); + } + + public void unload() { + runtime.unload(); + } + + private void instantiateNodes() { + Map definitions = new LinkedHashMap<>(BundledLuaLibrary.load()); + definitions.putAll(IntegrationLuaLibrary.load()); + definitions.putAll(program.library()); + for (GraphNode node : program.rootGraph().nodes()) { + LuaDefinitionSource source = definitions.get(node.definitionId()); + if (source == null) { + definitionDiagnostics.add(error( + "missing_definition", + "Missing Lua definition " + node.definitionId(), + node.id())); + continue; + } + if (source.origin() == LuaDefinitionSource.Origin.EMBEDDED + && !node.definitionHash().isBlank() + && !node.definitionHash().equals(source.hash())) { + definitionDiagnostics.add(error( + "definition_hash_mismatch", + "Definition hash changed for " + node.definitionId(), + node.id())); + continue; + } + try { + LuaNodeInstance instance = + runtime.createNode(node.id(), source.apiVersion(), source.source()); + if (!instance.definition().id().equals(node.definitionId())) { + throw new IllegalArgumentException("Definition source returned id " + instance.definition().id()); + } + validatePortSnapshot(node, instance.definition()); + restoreState(node, instance); + instances.put(node.id(), instance); + resolvedFields.put(node.id(), resolveFields(instance.definition(), node)); + outputs.put(node.id(), instance.outputs()); + } catch (RuntimeException exception) { + definitionDiagnostics.add(error( + "definition_load_failed", + exception.getMessage(), + node.id())); + } + } + } + + private void validatePortSnapshot(GraphNode node, LuaNodeDefinition definition) { + Map snapshots = new HashMap<>(); + node.ports().forEach(port -> snapshots.put(port.direction() + "\u0000" + port.id(), port)); + for (LuaPortSchema input : definition.inputs()) { + PortSnapshot snapshot = snapshots.get(PortDirection.INPUT + "\u0000" + input.id()); + if (snapshot != null && snapshot.type() != input.type()) { + throw new IllegalArgumentException("Input port type changed: " + input.id()); + } + } + for (LuaPortSchema output : definition.outputs()) { + PortSnapshot snapshot = snapshots.get(PortDirection.OUTPUT + "\u0000" + output.id()); + if (snapshot != null && snapshot.type() != output.type()) { + throw new IllegalArgumentException("Output port type changed: " + output.id()); + } + } + } + + private void restoreState(GraphNode node, LuaNodeInstance instance) { + CompoundTag encoded = program.persistentState().get(node.id()); + if (encoded == null) { + return; + } + LuaValue value = stateCodec.decode(encoded); + if (!value.istable()) { + throw new IllegalArgumentException("Persistent node state must be a table"); + } + instance.restoreState(fromTable(value.checktable())); + } + + private void resumeYielded(List diagnostics) { + instances.forEach((nodeId, instance) -> { + if (instance.status() != LuaNodeStatus.YIELDED) { + return; + } + LuaInvocationResult result = instance.resumeIfReady(); + outputs.put(nodeId, result.outputs()); + diagnostics.addAll(result.diagnostics()); + }); + } + + private Map inputs(LuaNodeDefinition definition, GraphNode node) { + Map values = new LinkedHashMap<>(); + definition.inputs().forEach(input -> values.put(input.id(), LuaValueCopies.copy(input.defaultValue()))); + for (GraphConnection connection : incoming.getOrDefault(node.id(), List.of())) { + Map sourceOutputs = outputs.get(connection.sourceNode()); + if (sourceOutputs == null) { + continue; + } + LuaValue value = sourceOutputs.get(connection.sourcePort()); + if (value != null) { + values.put(connection.targetPort(), LuaValueCopies.copy(value)); + } + } + return values; + } + + private Map fields(LuaNodeDefinition definition, GraphNode node) { + return resolvedFields.getOrDefault(node.id(), Map.of()); + } + + private Map resolveFields(LuaNodeDefinition definition, GraphNode node) { + Map values = new LinkedHashMap<>(); + for (LuaFieldSchema field : definition.fields()) { + CompoundTag encoded = node.fields().get(field.id()); + values.put( + field.id(), + encoded == null ? LuaValueCopies.copy(field.defaultValue()) : stateCodec.decode(encoded)); + } + return values; + } + + private boolean shouldRun( + LuaExecutionPolicy policy, + UUID nodeId, + Map inputs) { + return switch (policy) { + case TICK -> true; + case INPUT -> !same(lastInputs.get(nodeId), inputs); + case STEP -> stepRequested; + case EVENT -> false; + }; + } + + private void dispatchEvents(boolean preview, List diagnostics) { + int remaining = events.size(); + while (remaining-- > 0) { + GraphEvent event = events.removeFirst(); + for (UUID nodeId : analysis.executionOrder()) { + LuaNodeInstance instance = instances.get(nodeId); + GraphNode node = nodes.get(nodeId); + if (instance == null + || node == null + || (event.targetNode() != null && !event.targetNode().equals(nodeId)) + || !instance.definition().eventHandlers().containsKey(event.name()) + || instance.status() == LuaNodeStatus.YIELDED) { + continue; + } + LuaInvocationResult result = instance.event( + event.name(), + event.arguments(), + inputs(instance.definition(), node), + fields(instance.definition(), node), + tick, + runtime.nextGraphStep(), + preview, + (name, values) -> events.addLast(new GraphEvent(null, name, values))); + outputs.put(nodeId, result.outputs()); + diagnostics.addAll(result.diagnostics()); + } + } + } + + private boolean same(Map left, Map right) { + return LuaValueCopies.equivalent(left, right); + } + + private LuaTable toTable(Map values) { + LuaTable table = new LuaTable(); + values.forEach(table::set); + return table; + } + + private Map fromTable(LuaTable table) { + Map values = new LinkedHashMap<>(); + LuaValue key = LuaValue.NIL; + while (true) { + Varargs next = table.next(key); + key = next.arg1(); + if (key.isnil()) { + return values; + } + if (!key.isstring()) { + throw new IllegalArgumentException("Persistent state ids must be strings"); + } + values.put(key.tojstring(), next.arg(2)); + } + } + + private ComputedDiagnostic error(String code, String message, UUID nodeId) { + return new ComputedDiagnostic( + Severity.ERROR, + Phase.DEFINITION, + code, + message == null ? "" : message, + nodeId, + null, + null); + } + + private record GraphEvent(UUID targetNode, String name, List arguments) { + private GraphEvent { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Event name is required"); + } + arguments = arguments == null ? List.of() : List.copyOf(arguments); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/LuaGraphTickResult.java b/src/main/java/dev/propulsionteam/computed/graph/LuaGraphTickResult.java new file mode 100644 index 0000000..d875c70 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/LuaGraphTickResult.java @@ -0,0 +1,23 @@ +package dev.propulsionteam.computed.graph; + +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.luaj.vm2.LuaValue; + +public record LuaGraphTickResult( + Map> outputs, + List diagnostics, + long graphSteps) { + + public LuaGraphTickResult { + Map> copied = new LinkedHashMap<>(); + if (outputs != null) { + outputs.forEach((node, values) -> copied.put(node, Map.copyOf(values))); + } + outputs = java.util.Collections.unmodifiableMap(copied); + diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/PortDirection.java b/src/main/java/dev/propulsionteam/computed/graph/PortDirection.java new file mode 100644 index 0000000..9a7a2a0 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/PortDirection.java @@ -0,0 +1,6 @@ +package dev.propulsionteam.computed.graph; + +public enum PortDirection { + INPUT, + OUTPUT +} diff --git a/src/main/java/dev/propulsionteam/computed/graph/PortSnapshot.java b/src/main/java/dev/propulsionteam/computed/graph/PortSnapshot.java new file mode 100644 index 0000000..97afd7b --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/graph/PortSnapshot.java @@ -0,0 +1,15 @@ +package dev.propulsionteam.computed.graph; + +import dev.propulsionteam.computed.lua.node.ConnectionType; +import java.util.Objects; + +public record PortSnapshot(String id, PortDirection direction, ConnectionType type, String label) { + public PortSnapshot { + if (id == null || id.isBlank() || id.length() > 64) { + throw new IllegalArgumentException("Invalid port snapshot id: " + id); + } + Objects.requireNonNull(direction, "direction"); + Objects.requireNonNull(type, "type"); + label = label == null ? "" : label; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/CreateKineticBridge.java b/src/main/java/dev/propulsionteam/computed/integration/CreateKineticBridge.java deleted file mode 100644 index 9308582..0000000 --- a/src/main/java/dev/propulsionteam/computed/integration/CreateKineticBridge.java +++ /dev/null @@ -1,158 +0,0 @@ -package dev.propulsionteam.computed.integration; - -import java.lang.reflect.Method; -import java.util.HashMap; -import java.util.Map; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.neoforged.fml.ModList; -import org.jetbrains.annotations.Nullable; - -/** - * Reflection-based accessor for Create's kinetic block entities so the mod loads without Create. - * All public methods return defaults ({@code 0}, {@code false}) when Create is absent or reflection fails. - * - * In Create's stress model, {@code calculateStressApplied()} and {@code calculateAddedStressCapacity()} - * return base values multiplied by {@code abs(speed)} to get the actual SU values shown in the UI. - */ -public final class CreateKineticBridge { - private static final String KINETIC_BE = "com.simibubi.create.content.kinetics.base.KineticBlockEntity"; - - private static Boolean createPresent; - private static Class kineticClass; - private static boolean classResolved = false; - - /** Cache resolved methods per (class, methodName) to avoid per-tick reflection overhead. */ - private static final Map methodCache = new HashMap<>(); - /** Sentinel stored in cache when a method is confirmed absent, to skip future lookups. */ - private static final Method ABSENT = buildAbsentSentinel(); - - private CreateKineticBridge() {} - - public static boolean isCreateLoaded() { - if (createPresent == null) createPresent = ModList.get().isLoaded("create"); - return createPresent; - } - - @Nullable - public static BlockEntity getKineticBlockEntity(Level level, BlockPos pos) { - if (!isCreateLoaded()) return null; - Class cls = kineticClass(); - if (cls == null) return null; - try { - BlockEntity be = level.getBlockEntity(pos); - if (be == null || !cls.isInstance(be)) return null; - return be; - } catch (Throwable t) { - return null; - } - } - - public static boolean isKinetic(Level level, BlockPos pos) { - return getKineticBlockEntity(level, pos) != null; - } - - public static float getSpeed(Level level, BlockPos pos) { - BlockEntity be = getKineticBlockEntity(level, pos); - if (be == null) return 0f; - return invokeFloat(be, "getSpeed"); - } - - /** - * SU consumed by this block (0 for pure sources like motors). - * Internally: {@code calculateStressApplied()} × abs(speed). - */ - public static float getStress(Level level, BlockPos pos) { - BlockEntity be = getKineticBlockEntity(level, pos); - if (be == null) return 0f; - float base = invokeFloat(be, "calculateStressApplied"); - float speed = Math.abs(invokeFloat(be, "getSpeed")); - return base * speed; - } - - /** - * SU capacity generated by this block (0 for pure consumers like presses). - * Internally: {@code calculateAddedStressCapacity()} × abs(speed). - */ - public static float getCapacity(Level level, BlockPos pos) { - BlockEntity be = getKineticBlockEntity(level, pos); - if (be == null) return 0f; - float base = invokeFloat(be, "calculateAddedStressCapacity"); - float speed = Math.abs(invokeFloat(be, "getSpeed")); - return base * speed; - } - - // --- Reflection helpers --- - - /** - * Invoke a no-arg method and return the float result. - * Returns {@code 0f} on ANY failure — method not found, access denied, exception thrown. - * The entire call is wrapped so no exception can escape to the game thread. - */ - private static float invokeFloat(Object obj, String methodName) { - try { - Method m = resolveMethod(obj.getClass(), methodName); - if (m == null) return 0f; - Object result = m.invoke(obj); - return result instanceof Number n ? n.floatValue() : 0f; - } catch (Throwable t) { - return 0f; - } - } - - /** - * Resolve a no-arg method, with results cached per (declaring class, method name). - * Returns {@code null} if the method cannot be found or made accessible. - * Never throws — all exceptions are swallowed and recorded as ABSENT in the cache. - */ - @Nullable - private static Method resolveMethod(Class startClass, String methodName) { - String key = startClass.getName() + "#" + methodName; - Method cached = methodCache.get(key); - if (cached == ABSENT) return null; - if (cached != null) return cached; - - Method found = null; - Class cls = startClass; - while (cls != null && cls != Object.class) { - try { - Method m = cls.getDeclaredMethod(methodName); - try { - m.setAccessible(true); - } catch (Throwable ignored) { - // setAccessible failed (module restrictions); try next class in hierarchy - cls = cls.getSuperclass(); - continue; - } - found = m; - break; - } catch (NoSuchMethodException ignored) { - // not declared in this class, walk up - } catch (Throwable ignored) { - // any other reflection error — give up on this class - } - cls = cls.getSuperclass(); - } - - methodCache.put(key, found != null ? found : ABSENT); - return found; - } - - @Nullable - private static Class kineticClass() { - if (!classResolved) { - classResolved = true; - try { kineticClass = Class.forName(KINETIC_BE); } catch (Throwable ignored) {} - } - return kineticClass; - } - - private static Method buildAbsentSentinel() { - try { - return CreateKineticBridge.class.getDeclaredMethod("isCreateLoaded"); - } catch (NoSuchMethodException e) { - throw new AssertionError(e); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/integration/CreateRedstoneLinkBridge.java b/src/main/java/dev/propulsionteam/computed/integration/CreateRedstoneLinkBridge.java deleted file mode 100644 index 1ec0c7e..0000000 --- a/src/main/java/dev/propulsionteam/computed/integration/CreateRedstoneLinkBridge.java +++ /dev/null @@ -1,363 +0,0 @@ -package dev.propulsionteam.computed.integration; - -import dev.propulsionteam.computed.internal.node.api.FunctionCardNode; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import dev.propulsionteam.computed.content.nodes.create.CreateRedstoneLinkReceiverNode; -import dev.propulsionteam.computed.content.nodes.create.CreateRedstoneLinkSenderNode; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; -import java.util.ArrayList; -import java.util.List; -import java.util.function.IntSupplier; -import net.minecraft.core.BlockPos; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.Level; -import net.neoforged.fml.ModList; - -/** - * Registers virtual Create redstone link actors for Computed graph nodes, using reflection so Computed - * still loads when Create is absent. - */ -public final class CreateRedstoneLinkBridge { - private static final String CREATE = "com.simibubi.create.Create"; - private static final String HANDLER = "com.simibubi.create.content.redstone.link.RedstoneLinkNetworkHandler"; - private static final String FREQ = "com.simibubi.create.content.redstone.link.RedstoneLinkNetworkHandler$Frequency"; - private static final String IRL = "com.simibubi.create.content.redstone.link.IRedstoneLinkable"; - private static final String COUPLE = "net.createmod.catnip.data.Couple"; - - private static Boolean createPresent; - - private final List registered = new ArrayList<>(); - private boolean graphDirty = true; - - private static final class Registered { - final Object proxy; - final boolean transmit; - final IntSupplier transmitLevel; - int lastStrength = Integer.MIN_VALUE; - - Registered(Object proxy, boolean transmit, IntSupplier transmitLevel) { - this.proxy = proxy; - this.transmit = transmit; - this.transmitLevel = transmitLevel; - } - } - - public static boolean isCreateLoaded() { - if (createPresent != null) { - return createPresent; - } - createPresent = ModList.get().isLoaded("create"); - return createPresent; - } - - public void clear(Level level) { - if (!isCreateLoaded() || level == null || level.isClientSide) { - registered.clear(); - return; - } - Object handler = getHandler(); - if (handler == null) { - registered.clear(); - return; - } - try { - Method remove = handler.getClass().getMethod("removeFromNetwork", net.minecraft.world.level.LevelAccessor.class, Class.forName(IRL)); - for (Registered r : registered) { - remove.invoke(handler, level, r.proxy); - } - } catch (Throwable ignored) { - } - registered.clear(); - } - - public void markGraphDirty() { - graphDirty = true; - } - - public void ensureSynced(Level level, ComputerBlockEntity computer, WGraph graph) { - if (!graphDirty) { - return; - } - syncFromGraph(level, computer, graph); - } - - public void syncFromGraph(Level level, ComputerBlockEntity computer, WGraph graph) { - clear(level); - graphDirty = false; - if (!isCreateLoaded() || level == null || level.isClientSide || graph == null) { - return; - } - Object handler = getHandler(); - if (handler == null) { - return; - } - collect(level, computer, graph, handler); - } - - private void collect(Level level, ComputerBlockEntity computer, WGraph graph, Object handler) { - List senders = new ArrayList<>(); - List receivers = new ArrayList<>(); - gather(graph, senders, receivers); - for (CreateRedstoneLinkReceiverNode r : receivers) { - tryAddReceiver(level, computer, handler, r); - } - for (CreateRedstoneLinkSenderNode s : senders) { - tryAddSender(level, computer, handler, s); - } - } - - private void gather(WGraph graph, List senders, List receivers) { - for (WNode n : graph.getNodes()) { - if (n instanceof CreateRedstoneLinkSenderNode s) { - senders.add(s); - } else if (n instanceof CreateRedstoneLinkReceiverNode r) { - receivers.add(r); - } else if (n instanceof FunctionCardNode fc) { - gather(fc.getInnerGraph(), senders, receivers); - } - } - } - - private void tryAddSender(Level level, ComputerBlockEntity computer, Object handler, CreateRedstoneLinkSenderNode s) { - ItemStack a = s.redFrequency(); - ItemStack b = s.blueFrequency(); - Object couple = makeCouple(a, b); - if (couple == null) { - return; - } - Object proxy = makeProxy(computer, computer.getBlockPos(), couple, true, s::readTransmitStrength, p -> {}); - if (proxy == null) { - return; - } - invokeAdd(handler, level, proxy); - registered.add(new Registered(proxy, true, s::readTransmitStrength)); - for (BlockPos mirrorPos : sableMirrorAnchors(level, computer.getBlockPos())) { - Object mirror = makeProxy(computer, mirrorPos, couple, true, s::readTransmitStrength, p -> {}); - if (mirror != null) { - invokeAdd(handler, level, mirror); - registered.add(new Registered(mirror, true, s::readTransmitStrength)); - } - } - } - - private void tryAddReceiver(Level level, ComputerBlockEntity computer, Object handler, CreateRedstoneLinkReceiverNode r) { - ItemStack a = r.redFrequency(); - ItemStack b = r.blueFrequency(); - Object couple = makeCouple(a, b); - if (couple == null) { - return; - } - Object proxy = makeProxy(computer, computer.getBlockPos(), couple, false, () -> 0, r::setLinkInputStrength); - if (proxy == null) { - return; - } - invokeAdd(handler, level, proxy); - registered.add(new Registered(proxy, false, null)); - warmupReceiver(handler, level, couple, proxy); - for (BlockPos mirrorPos : sableMirrorAnchors(level, computer.getBlockPos())) { - Object mirror = makeProxy(computer, mirrorPos, couple, false, () -> 0, r::setLinkInputStrength); - if (mirror != null) { - invokeAdd(handler, level, mirror); - registered.add(new Registered(mirror, false, null)); - warmupReceiver(handler, level, couple, mirror); - } - } - } - - /** - * Create's {@code addToNetwork} immediately calls {@code updateNetworkOf(level, joiner)}, but - * {@code updateNetworkOf} explicitly skips the actor it pivots on when pushing — so a freshly - * added listener never gets the current network state from Create itself. We work around this - * by triggering {@code updateNetworkOf} on a different actor in the same network, which DOES - * push to our new proxy. If no other actor exists yet, the proxy stays at 0 until something - * external triggers, which is acceptable. - */ - private static void warmupReceiver(Object handler, Level level, Object couple, Object justAdded) { - try { - java.lang.reflect.Field connectionsField = handler.getClass().getDeclaredField("connections"); - connectionsField.setAccessible(true); - Object connections = connectionsField.get(handler); - java.util.Map perLevel = (java.util.Map) ((java.util.Map) connections).get(level); - if (perLevel == null) return; - Object network = perLevel.get(couple); - if (!(network instanceof Iterable iter)) return; - Object pivot = null; - for (Object actor : iter) { - if (actor != justAdded) { pivot = actor; break; } - } - if (pivot == null) return; - Method update = handler.getClass().getMethod("updateNetworkOf", net.minecraft.world.level.LevelAccessor.class, Class.forName(IRL)); - update.invoke(handler, level, pivot); - } catch (Throwable ignored) { - } - } - - /** - * Anchors for mirror proxies when Sable is loaded. Create's redstone link network groups by chunk/range, - * so a single proxy at the computer's position can't reach links living on sub-levels. - * We register one mirror anchored inside each sub-level on the same {@link Level} other than the one - * containing the computer (if any). The host-world side is already covered by the primary proxy when - * the computer is in the host world. - */ - private static List sableMirrorAnchors(Level level, BlockPos computerPos) { - if (!SableBridge.isLoaded()) { - return List.of(); - } - SableBridge.SubLevelHandle computerSub = SableBridge.containing(level, computerPos); - BlockPos computerSubAnchor = computerSub == null ? null : SableBridge.representativePos(computerSub); - List out = new ArrayList<>(); - for (SableBridge.SubLevelHandle sl : SableBridge.allSubLevels(level)) { - BlockPos anchor = SableBridge.representativePos(sl); - if (anchor == null) { - continue; - } - if (computerSubAnchor != null && anchor.equals(computerSubAnchor)) { - continue; - } - out.add(anchor); - } - return out; - } - - private static void invokeAdd(Object handler, Level level, Object proxy) { - try { - Method add = handler.getClass().getMethod("addToNetwork", net.minecraft.world.level.LevelAccessor.class, Class.forName(IRL)); - add.invoke(handler, level, proxy); - } catch (Throwable ignored) { - } - } - - public void pushTransmitters(Level level) { - if (!isCreateLoaded() || level == null || level.isClientSide) { - return; - } - Object handler = getHandler(); - if (handler == null) { - return; - } - try { - Method update = handler.getClass().getMethod("updateNetworkOf", net.minecraft.world.level.LevelAccessor.class, Class.forName(IRL)); - for (Registered r : registered) { - if (!r.transmit || r.transmitLevel == null) { - continue; - } - int strength = net.minecraft.util.Mth.clamp(r.transmitLevel.getAsInt(), 0, 15); - if (strength != r.lastStrength) { - r.lastStrength = strength; - update.invoke(handler, level, r.proxy); - } - } - } catch (Throwable ignored) { - } - } - - private static Object getHandler() { - try { - Class create = Class.forName(CREATE); - return create.getField("REDSTONE_LINK_NETWORK_HANDLER").get(null); - } catch (Throwable t) { - return null; - } - } - - private static Object frequencyOf(ItemStack stack) { - try { - Class fc = Class.forName(FREQ); - Method of = fc.getMethod("of", ItemStack.class); - return of.invoke(null, stack); - } catch (Throwable t) { - return null; - } - } - - private static Object makeCouple(ItemStack a, ItemStack b) { - try { - Object fa = frequencyOf(a.copyWithCount(1)); - Object fb = frequencyOf(b.copyWithCount(1)); - if (fa == null || fb == null) { - return null; - } - Class coupleC = Class.forName(COUPLE); - Method create = coupleC.getMethod("create", Object.class, Object.class); - return create.invoke(null, fa, fb); - } catch (Throwable t) { - return null; - } - } - - private static Object makeProxy( - ComputerBlockEntity computer, - BlockPos location, - Object couple, - boolean transmit, - java.util.function.IntSupplier transmitLevel, - java.util.function.IntConsumer receiveConsumer) { - try { - Class iface = Class.forName(IRL); - ClassLoader cl = iface.getClassLoader(); - BlockPos pinnedLocation = location.immutable(); - InvocationHandler h = - (Object proxy, Method method, Object[] args) -> { - String name = method.getName(); - if ("getTransmittedStrength".equals(name)) { - return transmit - ? net.minecraft.util.Mth.clamp(transmitLevel.getAsInt(), 0, 15) - : 0; - } - if ("setReceivedStrength".equals(name)) { - if (!transmit && args != null && args.length > 0 && args[0] instanceof Number num) { - receiveConsumer.accept(num.intValue()); - } - return null; - } - if ("isListening".equals(name)) { - return !transmit; - } - if ("isAlive".equals(name)) { - Level lvl = computer.getLevel(); - return lvl != null - && !computer.isRemoved() - && lvl.isLoaded(computer.getBlockPos()) - && lvl.getBlockEntity(computer.getBlockPos()) == computer; - } - if ("getNetworkKey".equals(name)) { - return couple; - } - if ("getLocation".equals(name)) { - return pinnedLocation; - } - if ("equals".equals(name)) { - return proxy == args[0]; - } - if ("hashCode".equals(name)) { - return System.identityHashCode(proxy); - } - if ("toString".equals(name)) { - return "ComputedVirtualLink"; - } - if (method.isDefault()) { - return InvocationHandler.invokeDefault(proxy, method, args); - } - Class ret = method.getReturnType(); - if (ret == boolean.class) return false; - if (ret == byte.class) return (byte) 0; - if (ret == short.class) return (short) 0; - if (ret == int.class) return 0; - if (ret == long.class) return 0L; - if (ret == float.class) return 0f; - if (ret == double.class) return 0d; - if (ret == char.class) return (char) 0; - return null; - }; - return Proxy.newProxyInstance(cl, new Class[] {iface}, h); - } catch (Throwable t) { - return null; - } - } - - public CreateRedstoneLinkBridge() {} -} diff --git a/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputedPeripheral.java b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputedPeripheral.java new file mode 100644 index 0000000..7c9ff59 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputedPeripheral.java @@ -0,0 +1,53 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import dan200.computercraft.api.lua.IArguments; +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.api.lua.LuaFunction; +import dan200.computercraft.api.lua.MethodResult; +import dan200.computercraft.api.peripheral.IComputerAccess; +import dan200.computercraft.api.peripheral.IPeripheral; +import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; + +public final class ComputedPeripheral implements IPeripheral { + private final ComputerBlockEntity computer; + + public ComputedPeripheral(ComputerBlockEntity computer) { + this.computer = computer; + } + + @Override + public String getType() { + return "computed"; + } + + @Override + public void attach(IComputerAccess access) { + ComputerCraftChannels.store(computer).attach(access); + } + + @Override + public void detach(IComputerAccess access) { + ComputerCraftChannels.store(computer).detach(access); + } + + @LuaFunction + public final MethodResult listChannels() { + return MethodResult.of(ComputerCraftChannels.store(computer).channels()); + } + + @LuaFunction + public final MethodResult read(IArguments arguments) throws LuaException { + return MethodResult.of(ComputerCraftChannels.store(computer).output(arguments.getString(0))); + } + + @LuaFunction + public final MethodResult write(IArguments arguments) throws LuaException { + ComputerCraftChannels.store(computer).write(arguments.getString(0), arguments.get(1)); + return MethodResult.of(); + } + + @Override + public boolean equals(IPeripheral other) { + return other instanceof ComputedPeripheral peripheral && peripheral.computer == computer; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftBootstrap.java b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftBootstrap.java new file mode 100644 index 0000000..f5395b5 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftBootstrap.java @@ -0,0 +1,37 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import dev.propulsionteam.computed.Computed; +import java.lang.reflect.InvocationTargetException; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.ModList; + +public final class ComputerCraftBootstrap { + private ComputerCraftBootstrap() {} + + public static boolean available() { + return ModList.get().isLoaded("computercraft"); + } + + public static void register(IEventBus modBus) { + if (!available()) { + return; + } + try { + Class.forName( + "dev.propulsionteam.computed.integration.computercraft.ComputerCraftIntegration", + true, + ComputerCraftBootstrap.class.getClassLoader()) + .getMethod("register", IEventBus.class) + .invoke(null, modBus); + } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException exception) { + throw new IllegalStateException("CC:Tweaked API bridge could not be loaded", exception); + } catch (InvocationTargetException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + Computed.LOGGER.error("CC:Tweaked API bridge failed during registration", cause); + throw new IllegalStateException("CC:Tweaked API bridge failed during registration", cause); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftChannels.java b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftChannels.java new file mode 100644 index 0000000..30a31a3 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftChannels.java @@ -0,0 +1,78 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.api.peripheral.IComputerAccess; +import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; + +final class ComputerCraftChannels { + private static final Map STORES = new WeakHashMap<>(); + + private ComputerCraftChannels() {} + + static synchronized Store store(ComputerBlockEntity computer) { + return STORES.computeIfAbsent(computer, ignored -> new Store()); + } + + static synchronized void remove(ComputerBlockEntity computer) { + STORES.remove(computer); + } + + static final class Store { + private final Map inputs = new LinkedHashMap<>(); + private final Map outputs = new LinkedHashMap<>(); + private final Set attached = new LinkedHashSet<>(); + + synchronized void attach(IComputerAccess computer) { + attached.add(computer); + } + + synchronized void detach(IComputerAccess computer) { + attached.remove(computer); + } + + synchronized void write(String channel, Object value) throws LuaException { + inputs.put(requireChannel(channel), ComputerCraftValueCodec.normalize(value)); + } + + synchronized Object input(String channel) { + return inputs.get(requireChannel(channel)); + } + + synchronized Object output(String channel) { + return outputs.get(requireChannel(channel)); + } + + synchronized void publish(String channel, Object value) throws LuaException { + String checked = requireChannel(channel); + Object normalized = ComputerCraftValueCodec.normalize(value); + if (java.util.Objects.deepEquals(outputs.put(checked, normalized), normalized)) { + return; + } + List listeners = new ArrayList<>(attached); + for (IComputerAccess listener : listeners) { + listener.queueEvent("computed_output_changed", checked, normalized); + } + } + + synchronized List channels() { + Set names = new LinkedHashSet<>(inputs.keySet()); + names.addAll(outputs.keySet()); + return List.copyOf(names); + } + + private static String requireChannel(String channel) { + String checked = channel == null ? "" : channel.strip(); + if (checked.isEmpty() || checked.length() > 64) { + throw new IllegalArgumentException("Channel names must contain 1 to 64 characters"); + } + return checked; + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftIntegration.java b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftIntegration.java new file mode 100644 index 0000000..a7c67f2 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftIntegration.java @@ -0,0 +1,207 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.api.peripheral.IPeripheral; +import dan200.computercraft.api.peripheral.PeripheralCapability; +import dev.propulsionteam.computed.content.ComputedRegistries; +import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; +import dev.propulsionteam.computed.lua.endpoint.ComputedEndpoints; +import dev.propulsionteam.computed.lua.endpoint.EndpointInvocation; +import dev.propulsionteam.computed.lua.endpoint.EndpointPolicy; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.endpoint.EndpointRuntimeLifecycle; +import dev.propulsionteam.computed.lua.endpoint.EndpointSignature; +import dev.propulsionteam.computed.lua.endpoint.EndpointType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import net.minecraft.core.Direction; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +public final class ComputerCraftIntegration { + private static final AtomicBoolean REGISTERED = new AtomicBoolean(); + private static final Map PERIPHERALS = + Collections.synchronizedMap(new WeakHashMap<>()); + private static final Set CALLS = + Collections.newSetFromMap(new IdentityHashMap<>()); + + private ComputerCraftIntegration() {} + + public static void register(IEventBus modBus) { + if (!REGISTERED.compareAndSet(false, true)) { + return; + } + registerEndpoints(); + modBus.addListener(ComputerCraftIntegration::registerCapabilities); + EndpointRuntimeLifecycle.register(new EndpointRuntimeLifecycle.Listener() { + @Override + public void tick(java.util.UUID computerId, Object host) { + if (host instanceof ComputerBlockEntity computer) { + poll(computer); + } + } + + @Override + public void unload(java.util.UUID computerId, Object host) { + if (host instanceof ComputerBlockEntity computer) { + cancel(computer); + ComputerCraftChannels.remove(computer); + } + } + }); + } + + static Optional findPeripheral(ComputerBlockEntity computer, Direction direction) { + if (computer.getLevel() == null || direction == null) { + return Optional.empty(); + } + IPeripheral peripheral = computer.getLevel().getCapability( + PeripheralCapability.get(), + computer.getBlockPos().relative(direction), + direction.getOpposite()); + return Optional.ofNullable(peripheral); + } + + static synchronized void track(ComputerCraftPeripheralCall call) { + CALLS.add(call); + } + + static synchronized void untrack(ComputerCraftPeripheralCall call) { + CALLS.remove(call); + } + + private static void registerCapabilities(RegisterCapabilitiesEvent event) { + event.registerBlockEntity( + PeripheralCapability.get(), + ComputedRegistries.COMPUTER_BLOCK_ENTITY.get(), + (computer, side) -> PERIPHERALS.computeIfAbsent(computer, ComputedPeripheral::new)); + } + + private static void registerEndpoints() { + ComputedEndpoints.register("computercraft:channel", endpoint -> endpoint.method( + "read", + EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.TABLE)), + EndpointPolicy.computerThread(false, true), + ComputerCraftIntegration::readChannel, + ignored -> new EndpointResult.Immediate(List.of(new LuaTable())), + "Reads a named value written by an attached CC computer.") + .method( + "publish", + EndpointSignature.of( + List.of(EndpointType.STRING, EndpointType.TABLE), + List.of()), + EndpointPolicy.computerThread(true, false), + ComputerCraftIntegration::publishChannel, + null, + "Publishes a named graph value to attached CC computers.")); + ComputedEndpoints.register("computercraft:peripheral", endpoint -> endpoint.method( + "methods", + EndpointSignature.of(List.of(), List.of(EndpointType.TABLE)), + EndpointPolicy.computerThread(false, false), + ComputerCraftIntegration::peripheralMethods, + null, + "Lists methods exposed by the adjacent CC peripheral.") + .method( + "call", + new EndpointSignature( + List.of(EndpointType.STRING), + List.of(EndpointType.TABLE), + true), + new EndpointPolicy( + EndpointPolicy.ExecutionSide.SERVER_THREAD, + true, + true, + false), + ComputerCraftIntegration::callPeripheral, + null, + "Calls an adjacent CC peripheral method and resumes yielded results.")); + } + + private static EndpointResult readChannel(EndpointInvocation invocation) throws LuaException { + ComputerBlockEntity computer = requireComputer(invocation); + Object value = ComputerCraftChannels.store(computer) + .input(invocation.arguments().getFirst().checkjstring()); + LuaTable wrapper = new LuaTable(); + wrapper.set("value", ComputerCraftValueCodec.toLua(value)); + return new EndpointResult.Immediate(List.of(wrapper)); + } + + private static EndpointResult publishChannel(EndpointInvocation invocation) throws LuaException { + ComputerBlockEntity computer = requireComputer(invocation); + ComputerCraftChannels.store(computer).publish( + invocation.arguments().get(0).checkjstring(), + ComputerCraftValueCodec.toJava(invocation.arguments().get(1))); + return new EndpointResult.Immediate(List.of()); + } + + private static EndpointResult peripheralMethods(EndpointInvocation invocation) throws LuaException { + ResolvedPeripheral resolved = requirePeripheral(invocation); + LuaTable methods = new LuaTable(); + List names = ComputerCraftPeripheralCall.methods(resolved.peripheral()); + for (int index = 0; index < names.size(); index++) { + methods.set(index + 1, names.get(index)); + } + return new EndpointResult.Immediate(List.of(methods)); + } + + private static EndpointResult callPeripheral(EndpointInvocation invocation) throws LuaException { + ResolvedPeripheral resolved = requirePeripheral(invocation); + String method = invocation.arguments().getFirst().checkjstring(); + List arguments = new ArrayList<>(); + for (int index = 1; index < invocation.arguments().size(); index++) { + arguments.add(ComputerCraftValueCodec.toJava(invocation.arguments().get(index))); + } + return ComputerCraftPeripheralCall.invoke( + resolved.computer(), + resolved.direction().getName(), + resolved.peripheral(), + method, + arguments); + } + + private static ComputerBlockEntity requireComputer(EndpointInvocation invocation) throws LuaException { + if (invocation.host() instanceof ComputerBlockEntity computer) { + return computer; + } + throw new LuaException("CC:Tweaked endpoints require a server computer"); + } + + private static ResolvedPeripheral requirePeripheral(EndpointInvocation invocation) throws LuaException { + ComputerBlockEntity computer = requireComputer(invocation); + Direction direction = computer.worldFaceForEndpoint(invocation.target()); + if (direction == null) { + throw new LuaException("Unknown computer side: " + invocation.target()); + } + IPeripheral peripheral = findPeripheral(computer, direction) + .orElseThrow(() -> new LuaException("No CC peripheral is attached on " + invocation.target())); + return new ResolvedPeripheral(computer, direction, peripheral); + } + + private static synchronized void poll(ComputerBlockEntity computer) { + List invalid = CALLS.stream() + .filter(call -> !call.valid()) + .toList(); + invalid.forEach(call -> call.cancel("CC peripheral detached while call was yielded")); + } + + private static synchronized void cancel(ComputerBlockEntity computer) { + List pending = CALLS.stream() + .filter(call -> call.belongsTo(computer)) + .toList(); + pending.forEach(call -> call.cancel("Computer unloaded while CC call was yielded")); + } + + private record ResolvedPeripheral( + ComputerBlockEntity computer, + Direction direction, + IPeripheral peripheral) {} +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftPeripheralCall.java b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftPeripheralCall.java new file mode 100644 index 0000000..2c71d9a --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftPeripheralCall.java @@ -0,0 +1,363 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import dan200.computercraft.api.filesystem.Mount; +import dan200.computercraft.api.filesystem.WritableMount; +import dan200.computercraft.api.lua.IArguments; +import dan200.computercraft.api.lua.ILuaCallback; +import dan200.computercraft.api.lua.ILuaContext; +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.api.lua.LuaFunction; +import dan200.computercraft.api.lua.LuaTask; +import dan200.computercraft.api.lua.MethodResult; +import dan200.computercraft.api.lua.ObjectArguments; +import dan200.computercraft.api.peripheral.IComputerAccess; +import dan200.computercraft.api.peripheral.IDynamicPeripheral; +import dan200.computercraft.api.peripheral.IPeripheral; +import dan200.computercraft.api.peripheral.WorkMonitor; +import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import net.minecraft.core.Direction; + +final class ComputerCraftPeripheralCall { + private static final AtomicLong TASK_IDS = new AtomicLong(); + + private final ComputerBlockEntity computer; + private final String directionName; + private final IPeripheral peripheral; + private final CompletableFuture future = new CompletableFuture<>(); + private final Access access = new Access(); + private final Context context = new Context(); + private ILuaCallback callback; + private boolean closed; + + private ComputerCraftPeripheralCall( + ComputerBlockEntity computer, + String directionName, + IPeripheral peripheral) { + this.computer = computer; + this.directionName = directionName; + this.peripheral = peripheral; + } + + static List methods(IPeripheral peripheral) { + Map annotated = annotatedMethods(peripheral); + List names = new ArrayList<>(annotated.keySet()); + if (peripheral instanceof IDynamicPeripheral dynamic) { + for (String name : dynamic.getMethodNames()) { + if (!names.contains(name)) { + names.add(name); + } + } + } + names.sort(String.CASE_INSENSITIVE_ORDER); + return List.copyOf(names); + } + + static EndpointResult invoke( + ComputerBlockEntity computer, + String directionName, + IPeripheral peripheral, + String methodName, + List arguments) throws LuaException { + ComputerCraftPeripheralCall call = + new ComputerCraftPeripheralCall(computer, directionName, peripheral); + peripheral.attach(call.access); + try { + MethodResult result = call.invokeMethod(methodName, arguments); + if (result.getCallback() == null) { + call.close(); + return immediate(result.getResult()); + } + call.callback = result.getCallback(); + ComputerCraftIntegration.track(call); + call.future.whenComplete((value, error) -> call.close()); + return new EndpointResult.Yielded(call.future); + } catch (LuaException | RuntimeException exception) { + call.close(); + throw exception; + } + } + + boolean valid() { + return !closed + && !computer.isRemoved() + && ComputerCraftIntegration.findPeripheral(computer, Direction.byName(directionName)) + .map(current -> current.equals(peripheral) || peripheral.equals(current)) + .orElse(false); + } + + boolean belongsTo(ComputerBlockEntity computer) { + return this.computer == computer; + } + + void cancel(String reason) { + future.completeExceptionally(new IllegalStateException(reason)); + close(); + } + + private MethodResult invokeMethod(String methodName, List arguments) throws LuaException { + if (peripheral instanceof IDynamicPeripheral dynamic) { + String[] names = dynamic.getMethodNames(); + for (int index = 0; index < names.length; index++) { + if (names[index].equals(methodName)) { + return dynamic.callMethod( + access, + context, + index, + new ObjectArguments(arguments)); + } + } + } + Method method = annotatedMethods(peripheral).get(methodName); + if (method == null) { + throw new LuaException("Peripheral method is unavailable: " + methodName); + } + return invokeAnnotated(method, arguments); + } + + private MethodResult invokeAnnotated(Method method, List arguments) throws LuaException { + Object[] invocationArguments = bind(method, arguments); + try { + Object result = method.invoke(peripheral, invocationArguments); + if (result instanceof MethodResult methodResult) { + return methodResult; + } + if (result instanceof Object[] values) { + return MethodResult.of(values); + } + return result == null ? MethodResult.of() : MethodResult.of(result); + } catch (IllegalAccessException exception) { + throw new LuaException("Peripheral method is not accessible"); + } catch (InvocationTargetException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof LuaException lua) { + throw lua; + } + throw new LuaException(cause == null ? exception.getMessage() : cause.getMessage()); + } + } + + private Object[] bind(Method method, List arguments) throws LuaException { + Object[] bound = new Object[method.getParameterCount()]; + int argumentIndex = 0; + Parameter[] parameters = method.getParameters(); + for (int index = 0; index < parameters.length; index++) { + Class type = parameters[index].getType(); + if (type == IComputerAccess.class) { + bound[index] = access; + } else if (type == ILuaContext.class) { + bound[index] = context; + } else if (type == IArguments.class) { + bound[index] = new ObjectArguments(arguments.subList(argumentIndex, arguments.size())); + argumentIndex = arguments.size(); + } else { + if (argumentIndex >= arguments.size()) { + throw new LuaException("Peripheral method received too few arguments"); + } + bound[index] = coerce(arguments.get(argumentIndex++), type); + } + } + if (argumentIndex != arguments.size()) { + throw new LuaException("Peripheral method received too many arguments"); + } + return bound; + } + + private static Object coerce(Object value, Class target) throws LuaException { + if (value == null && !target.isPrimitive()) { + return null; + } + if (target == Object.class || target.isInstance(value)) { + return value; + } + if (target == String.class) { + return Objects.toString(value, ""); + } + if (target == boolean.class || target == Boolean.class) { + if (value instanceof Boolean bool) { + return bool; + } + } + if (value instanceof Number number) { + if (target == double.class || target == Double.class) { + return number.doubleValue(); + } + if (target == float.class || target == Float.class) { + return number.floatValue(); + } + if (target == long.class || target == Long.class) { + return number.longValue(); + } + if (target == int.class || target == Integer.class) { + return number.intValue(); + } + if (target == short.class || target == Short.class) { + return number.shortValue(); + } + if (target == byte.class || target == Byte.class) { + return number.byteValue(); + } + } + throw new LuaException("Cannot convert peripheral argument to " + target.getSimpleName()); + } + + private synchronized void queueEvent(String eventName, Object... arguments) { + if (callback == null || closed) { + return; + } + Object[] event = new Object[(arguments == null ? 0 : arguments.length) + 1]; + event[0] = eventName; + if (arguments != null) { + System.arraycopy(arguments, 0, event, 1, arguments.length); + } + try { + MethodResult result = callback.resume(event); + if (result.getCallback() == null) { + callback = null; + future.complete(immediate(result.getResult())); + } else { + callback = result.getCallback(); + } + } catch (LuaException exception) { + future.completeExceptionally(exception); + } + } + + private synchronized void close() { + if (closed) { + return; + } + closed = true; + callback = null; + peripheral.detach(access); + ComputerCraftIntegration.untrack(this); + } + + private static EndpointResult.Immediate immediate(Object[] values) throws LuaException { + return new EndpointResult.Immediate(List.of(ComputerCraftValueCodec.results(values))); + } + + private static Map annotatedMethods(IPeripheral peripheral) { + Map methods = new LinkedHashMap<>(); + Arrays.stream(peripheral.getClass().getMethods()).forEach(method -> { + LuaFunction annotation = method.getAnnotation(LuaFunction.class); + if (annotation == null) { + return; + } + String[] aliases = annotation.value(); + if (aliases.length == 0) { + methods.putIfAbsent(method.getName(), method); + } else { + for (String alias : aliases) { + methods.putIfAbsent(alias, method); + } + } + }); + return methods; + } + + private final class Context implements ILuaContext { + @Override + public long issueMainThreadTask(LuaTask task) throws LuaException { + long id = TASK_IDS.incrementAndGet(); + if (computer.getLevel() == null || computer.getLevel().getServer() == null) { + throw new LuaException("Computer server is unavailable"); + } + computer.getLevel().getServer().execute(() -> { + try { + Object[] result = task.execute(); + Object[] event = new Object[(result == null ? 0 : result.length) + 2]; + event[0] = id; + event[1] = true; + if (result != null) { + System.arraycopy(result, 0, event, 2, result.length); + } + access.queueEvent("task_complete", event); + } catch (LuaException exception) { + access.queueEvent("task_complete", id, false, exception.getMessage()); + } + }); + return id; + } + } + + private final class Access implements IComputerAccess { + @Override + public String mount(String desiredLocation, Mount mount, String driveName) { + throw new UnsupportedOperationException("Filesystem mounts are unavailable through Computed"); + } + + @Override + public String mountWritable(String desiredLocation, WritableMount mount, String driveName) { + throw new UnsupportedOperationException("Filesystem mounts are unavailable through Computed"); + } + + @Override + public void unmount(String location) { + throw new UnsupportedOperationException("Filesystem mounts are unavailable through Computed"); + } + + @Override + public int getID() { + return computer.getOrCreateUuid().hashCode(); + } + + @Override + public void queueEvent(String event, Object... arguments) { + ComputerCraftPeripheralCall.this.queueEvent(event, arguments); + } + + @Override + public String getAttachmentName() { + return "computed_" + directionName; + } + + @Override + public Map getAvailablePeripherals() { + Map available = new LinkedHashMap<>(); + for (Direction candidate : Direction.values()) { + ComputerCraftIntegration.findPeripheral(computer, candidate) + .ifPresent(value -> available.put(candidate.getName(), value)); + } + return Map.copyOf(available); + } + + @Override + public IPeripheral getAvailablePeripheral(String name) { + Direction requested = Direction.byName(name); + return requested == null + ? null + : ComputerCraftIntegration.findPeripheral(computer, requested).orElse(null); + } + + @Override + public WorkMonitor getMainThreadMonitor() { + return new WorkMonitor() { + @Override + public boolean canWork() { + return true; + } + + @Override + public boolean shouldWork() { + return true; + } + + @Override + public void trackWork(long time, TimeUnit unit) {} + }; + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftValueCodec.java b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftValueCodec.java new file mode 100644 index 0000000..a65f57e --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftValueCodec.java @@ -0,0 +1,162 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import dan200.computercraft.api.lua.LuaException; +import java.lang.reflect.Array; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +final class ComputerCraftValueCodec { + private static final int MAX_DEPTH = 16; + + private ComputerCraftValueCodec() {} + + static Object toJava(LuaValue value) throws LuaException { + return toJava(value, 0, new IdentityHashMap<>()); + } + + static LuaValue toLua(Object value) throws LuaException { + return toLua(value, 0, new IdentityHashMap<>()); + } + + static LuaTable results(Object[] values) throws LuaException { + LuaTable result = new LuaTable(); + if (values != null) { + for (int index = 0; index < values.length; index++) { + result.set(index + 1, toLua(values[index])); + } + } + return result; + } + + static Object normalize(Object value) throws LuaException { + return toJava(toLua(value)); + } + + private static Object toJava( + LuaValue value, + int depth, + IdentityHashMap active) throws LuaException { + if (value == null || value.isnil()) { + return null; + } + if (value.isboolean()) { + return value.toboolean(); + } + if (value.isnumber()) { + double number = value.todouble(); + if (!Double.isFinite(number)) { + throw new LuaException("Non-finite numbers are not supported"); + } + return number; + } + if (value.isstring()) { + return value.tojstring(); + } + if (!value.istable()) { + throw new LuaException("Unsupported Lua value: " + value.typename()); + } + if (depth >= MAX_DEPTH) { + throw new LuaException("Table nesting exceeds 16 levels"); + } + LuaTable table = value.checktable(); + if (active.put(table, Boolean.TRUE) != null) { + throw new LuaException("Cyclic tables are not supported"); + } + Map result = new LinkedHashMap<>(); + LuaValue key = LuaValue.NIL; + while (true) { + Varargs next = table.next(key); + key = next.arg1(); + if (key.isnil()) { + break; + } + Object convertedKey; + if (key.isint()) { + convertedKey = key.toint(); + } else if (key.isstring()) { + convertedKey = key.tojstring(); + } else { + throw new LuaException("Table keys must be strings or integers"); + } + result.put(convertedKey, toJava(next.arg(2), depth + 1, active)); + } + active.remove(table); + return result; + } + + private static LuaValue toLua( + Object value, + int depth, + IdentityHashMap active) throws LuaException { + if (value == null) { + return LuaValue.NIL; + } + if (value instanceof LuaValue lua) { + return toLua(toJava(lua), depth, active); + } + if (value instanceof Boolean bool) { + return LuaValue.valueOf(bool); + } + if (value instanceof Number number) { + double converted = number.doubleValue(); + if (!Double.isFinite(converted)) { + throw new LuaException("Non-finite numbers are not supported"); + } + return LuaValue.valueOf(converted); + } + if (value instanceof Character character) { + return LuaValue.valueOf(character.toString()); + } + if (value instanceof String string) { + return LuaValue.valueOf(string); + } + if (value instanceof ByteBuffer buffer) { + ByteBuffer copied = buffer.slice(); + byte[] bytes = new byte[copied.remaining()]; + copied.get(bytes); + return LuaValue.valueOf(bytes); + } + if (depth >= MAX_DEPTH) { + throw new LuaException("Table nesting exceeds 16 levels"); + } + if (active.put(value, Boolean.TRUE) != null) { + throw new LuaException("Cyclic tables are not supported"); + } + LuaTable table = new LuaTable(); + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + LuaValue key = switch (entry.getKey()) { + case String string -> LuaValue.valueOf(string); + case Byte byteValue -> LuaValue.valueOf(byteValue.intValue()); + case Short shortValue -> LuaValue.valueOf(shortValue.intValue()); + case Integer integer -> LuaValue.valueOf(integer); + case Long longValue when longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE -> + LuaValue.valueOf(longValue.intValue()); + default -> throw new LuaException("Table keys must be strings or integers"); + }; + table.set(key, toLua(entry.getValue(), depth + 1, active)); + } + } else if (value instanceof Iterable iterable) { + int index = 1; + for (Object item : iterable) { + table.set(index++, toLua(item, depth + 1, active)); + } + } else if (value.getClass().isArray()) { + for (int index = 0; index < Array.getLength(value); index++) { + table.set(index + 1, toLua(Array.get(value, index), depth + 1, active)); + } + } else { + active.remove(value); + throw new LuaException("Unsupported ComputerCraft value: " + value.getClass().getName()); + } + active.remove(value); + return table; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/create/CreateIntegration.java b/src/main/java/dev/propulsionteam/computed/integration/create/CreateIntegration.java new file mode 100644 index 0000000..d64293f --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/create/CreateIntegration.java @@ -0,0 +1,141 @@ +package dev.propulsionteam.computed.integration.create; + +import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; +import dev.propulsionteam.computed.lua.endpoint.ComputedEndpoints; +import dev.propulsionteam.computed.lua.endpoint.EndpointInvocation; +import dev.propulsionteam.computed.lua.endpoint.EndpointPolicy; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.endpoint.EndpointRuntimeLifecycle; +import dev.propulsionteam.computed.lua.endpoint.EndpointSignature; +import dev.propulsionteam.computed.lua.endpoint.EndpointType; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.neoforged.fml.ModList; +import org.luaj.vm2.LuaValue; + +public final class CreateIntegration { + private static final AtomicBoolean REGISTERED = new AtomicBoolean(); + + private CreateIntegration() {} + + public static void register() { + if (!REGISTERED.compareAndSet(false, true)) { + return; + } + ComputedEndpoints.register("create:kinetic", endpoint -> endpoint.method( + "speed", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> number(kinetic(invocation, Metric.SPEED)), + ignored -> number(0), + "Returns the adjacent Create kinetic speed.") + .method( + "stress", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> number(kinetic(invocation, Metric.STRESS)), + ignored -> number(0), + "Returns the adjacent Create kinetic stress.") + .method( + "capacity", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> number(kinetic(invocation, Metric.CAPACITY)), + ignored -> number(0), + "Returns the adjacent Create kinetic capacity.")); + ComputedEndpoints.register("create:redstone_link", endpoint -> endpoint.method( + "receive", + EndpointSignature.of( + List.of(EndpointType.STRING, EndpointType.STRING), + List.of(EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, false), + CreateIntegration::receive, + null, + "Reads a Create redstone-link frequency pair.") + .method( + "transmit", + EndpointSignature.of( + List.of(EndpointType.STRING, EndpointType.STRING, EndpointType.NUMBER), + List.of()), + EndpointPolicy.computerThread(true, false), + CreateIntegration::transmit, + null, + "Writes a Create redstone-link frequency pair.")); + EndpointRuntimeLifecycle.register(new EndpointRuntimeLifecycle.Listener() { + @Override + public void unload(java.util.UUID computerId, Object host) { + if (host instanceof ComputerBlockEntity computer) { + CreateRedstoneLinks.clear(computer); + } + } + }); + } + + private static double kinetic(EndpointInvocation invocation, Metric metric) { + ComputerBlockEntity computer = requireComputer(invocation); + requireCreate(); + Direction direction = requireDirection(computer, invocation.target()); + BlockPos target = computer.getBlockPos().relative(direction); + return switch (metric) { + case SPEED -> CreateKineticAccess.speed(computer.getLevel(), target); + case STRESS -> CreateKineticAccess.stress(computer.getLevel(), target); + case CAPACITY -> CreateKineticAccess.capacity(computer.getLevel(), target); + }; + } + + private static EndpointResult receive(EndpointInvocation invocation) { + ComputerBlockEntity computer = requireComputer(invocation); + requireCreate(); + int strength = CreateRedstoneLinks.receive( + computer, + invocation.nodeId(), + invocation.arguments().get(0).checkjstring(), + invocation.arguments().get(1).checkjstring()); + return number(strength); + } + + private static EndpointResult transmit(EndpointInvocation invocation) { + ComputerBlockEntity computer = requireComputer(invocation); + requireCreate(); + CreateRedstoneLinks.transmit( + computer, + invocation.nodeId(), + invocation.arguments().get(0).checkjstring(), + invocation.arguments().get(1).checkjstring(), + invocation.arguments().get(2).checkint()); + return new EndpointResult.Immediate(List.of()); + } + + private static ComputerBlockEntity requireComputer(EndpointInvocation invocation) { + if (invocation.host() instanceof ComputerBlockEntity computer && computer.getLevel() != null) { + return computer; + } + throw new IllegalStateException("Create endpoints require a server computer"); + } + + private static Direction requireDirection(ComputerBlockEntity computer, String target) { + Direction direction = computer.worldFaceForEndpoint(target); + if (direction == null) { + throw new IllegalArgumentException("Unknown computer side: " + target); + } + return direction; + } + + private static void requireCreate() { + if (!ModList.get().isLoaded("create")) { + throw new IllegalStateException("Create is not installed"); + } + } + + private static EndpointResult.Immediate number(double value) { + return new EndpointResult.Immediate(List.of(LuaValue.valueOf(value))); + } + + private enum Metric { + SPEED, + STRESS, + CAPACITY + } +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/create/CreateKineticAccess.java b/src/main/java/dev/propulsionteam/computed/integration/create/CreateKineticAccess.java new file mode 100644 index 0000000..bc321ef --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/create/CreateKineticAccess.java @@ -0,0 +1,87 @@ +package dev.propulsionteam.computed.integration.create; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; + +final class CreateKineticAccess { + private static final String KINETIC = + "com.simibubi.create.content.kinetics.base.KineticBlockEntity"; + private static final Map METHODS = new ConcurrentHashMap<>(); + private static volatile Class kineticClass; + private static volatile boolean resolved; + + private CreateKineticAccess() {} + + static double speed(Level level, BlockPos pos) { + return invoke(blockEntity(level, pos), "getSpeed"); + } + + static double stress(Level level, BlockPos pos) { + Object blockEntity = blockEntity(level, pos); + return invoke(blockEntity, "calculateStressApplied") + * Math.abs(invoke(blockEntity, "getSpeed")); + } + + static double capacity(Level level, BlockPos pos) { + Object blockEntity = blockEntity(level, pos); + return invoke(blockEntity, "calculateAddedStressCapacity") + * Math.abs(invoke(blockEntity, "getSpeed")); + } + + private static Object blockEntity(Level level, BlockPos pos) { + Class type = kineticClass(); + if (level == null || pos == null || type == null) { + return null; + } + BlockEntity blockEntity = level.getBlockEntity(pos); + return blockEntity != null && type.isInstance(blockEntity) ? blockEntity : null; + } + + private static double invoke(Object target, String name) { + if (target == null) { + return 0; + } + String key = target.getClass().getName() + '#' + name; + try { + Method method = METHODS.computeIfAbsent(key, ignored -> find(target.getClass(), name)); + Object result = method.invoke(target); + return result instanceof Number number ? number.doubleValue() : 0; + } catch (RuntimeException | ReflectiveOperationException exception) { + return 0; + } + } + + private static Method find(Class type, String name) { + Class current = type; + while (current != null && current != Object.class) { + try { + Method method = current.getDeclaredMethod(name); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException exception) { + current = current.getSuperclass(); + } + } + throw new IllegalArgumentException("Create kinetic method is unavailable: " + name); + } + + private static Class kineticClass() { + if (!resolved) { + synchronized (CreateKineticAccess.class) { + if (!resolved) { + try { + kineticClass = Class.forName(KINETIC); + } catch (ClassNotFoundException ignored) { + kineticClass = null; + } + resolved = true; + } + } + } + return kineticClass; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/integration/create/CreateRedstoneLinks.java b/src/main/java/dev/propulsionteam/computed/integration/create/CreateRedstoneLinks.java new file mode 100644 index 0000000..da82267 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/integration/create/CreateRedstoneLinks.java @@ -0,0 +1,269 @@ +package dev.propulsionteam.computed.integration.create; + +import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; +import java.util.function.IntConsumer; +import java.util.function.IntSupplier; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +final class CreateRedstoneLinks { + private static final String CREATE = "com.simibubi.create.Create"; + private static final String HANDLER = + "com.simibubi.create.content.redstone.link.RedstoneLinkNetworkHandler"; + private static final String FREQUENCY = + "com.simibubi.create.content.redstone.link.RedstoneLinkNetworkHandler$Frequency"; + private static final String LINKABLE = + "com.simibubi.create.content.redstone.link.IRedstoneLinkable"; + private static final String COUPLE = "net.createmod.catnip.data.Couple"; + private static final Map> ACTORS = new WeakHashMap<>(); + + private CreateRedstoneLinks() {} + + static synchronized int receive( + ComputerBlockEntity computer, + UUID nodeId, + String first, + String second) { + Key key = new Key(nodeId, false); + Actor actor = actor(computer, key, first, second); + return actor == null ? 0 : actor.received; + } + + static synchronized void transmit( + ComputerBlockEntity computer, + UUID nodeId, + String first, + String second, + int strength) { + Key key = new Key(nodeId, true); + Actor actor = actor(computer, key, first, second); + if (actor == null) { + return; + } + int clamped = Mth.clamp(strength, 0, 15); + if (actor.transmitted != clamped) { + actor.transmitted = clamped; + update(computer.getLevel(), actor.proxy); + } + } + + static synchronized void clear(ComputerBlockEntity computer) { + Map actors = ACTORS.remove(computer); + if (actors == null || computer.getLevel() == null) { + return; + } + Object handler = handler(); + if (handler == null) { + return; + } + for (Actor actor : actors.values()) { + remove(handler, computer.getLevel(), actor.proxy); + } + } + + private static Actor actor( + ComputerBlockEntity computer, + Key key, + String first, + String second) { + if (computer.getLevel() == null || computer.getLevel().isClientSide) { + return null; + } + Object networkKey = networkKey(first, second); + if (networkKey == null) { + return null; + } + Map actors = ACTORS.computeIfAbsent(computer, ignored -> new LinkedHashMap<>()); + Actor current = actors.get(key); + String signature = first + '\u0000' + second; + if (current != null && current.signature.equals(signature)) { + return current; + } + if (current != null) { + Object handler = handler(); + if (handler != null) { + remove(handler, computer.getLevel(), current.proxy); + } + } + Actor created = new Actor(signature); + Object proxy = proxy( + computer, + networkKey, + key.transmit(), + () -> created.transmitted, + value -> created.received = Mth.clamp(value, 0, 15)); + if (proxy == null) { + return null; + } + created.proxy = proxy; + Object handler = handler(); + if (handler == null || !add(handler, computer.getLevel(), proxy)) { + return null; + } + actors.put(key, created); + return created; + } + + private static Object proxy( + ComputerBlockEntity computer, + Object networkKey, + boolean transmit, + IntSupplier transmitted, + IntConsumer received) { + try { + Class type = Class.forName(LINKABLE); + BlockPos position = computer.getBlockPos().immutable(); + InvocationHandler invocation = (proxy, method, arguments) -> switch (method.getName()) { + case "getTransmittedStrength" -> transmit ? Mth.clamp(transmitted.getAsInt(), 0, 15) : 0; + case "setReceivedStrength" -> { + if (!transmit && arguments != null && arguments[0] instanceof Number number) { + received.accept(number.intValue()); + } + yield null; + } + case "isListening" -> !transmit; + case "isAlive" -> computer.getLevel() != null + && !computer.isRemoved() + && computer.getLevel().getBlockEntity(computer.getBlockPos()) == computer; + case "getNetworkKey" -> networkKey; + case "getLocation" -> position; + case "equals" -> proxy == arguments[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> "ComputedLuaRedstoneLink"; + default -> defaultValue(method.getReturnType()); + }; + return Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, invocation); + } catch (ReflectiveOperationException exception) { + return null; + } + } + + private static Object networkKey(String first, String second) { + try { + Object firstFrequency = frequency(first); + Object secondFrequency = frequency(second); + if (firstFrequency == null || secondFrequency == null) { + return null; + } + return Class.forName(COUPLE) + .getMethod("create", Object.class, Object.class) + .invoke(null, firstFrequency, secondFrequency); + } catch (ReflectiveOperationException exception) { + return null; + } + } + + private static Object frequency(String itemId) { + try { + ResourceLocation id = ResourceLocation.parse(itemId); + Item item = BuiltInRegistries.ITEM.get(id); + ItemStack stack = new ItemStack(item); + return Class.forName(FREQUENCY).getMethod("of", ItemStack.class).invoke(null, stack); + } catch (RuntimeException | ReflectiveOperationException exception) { + return null; + } + } + + private static Object handler() { + try { + return Class.forName(CREATE).getField("REDSTONE_LINK_NETWORK_HANDLER").get(null); + } catch (ReflectiveOperationException exception) { + return null; + } + } + + private static boolean add(Object handler, Level level, Object proxy) { + try { + handler.getClass() + .getMethod( + "addToNetwork", + net.minecraft.world.level.LevelAccessor.class, + Class.forName(LINKABLE)) + .invoke(handler, level, proxy); + return true; + } catch (ReflectiveOperationException exception) { + return false; + } + } + + private static void remove(Object handler, Level level, Object proxy) { + try { + handler.getClass() + .getMethod( + "removeFromNetwork", + net.minecraft.world.level.LevelAccessor.class, + Class.forName(LINKABLE)) + .invoke(handler, level, proxy); + } catch (ReflectiveOperationException ignored) { + } + } + + private static void update(Level level, Object proxy) { + Object handler = handler(); + if (handler == null || level == null) { + return; + } + try { + handler.getClass() + .getMethod( + "updateNetworkOf", + net.minecraft.world.level.LevelAccessor.class, + Class.forName(LINKABLE)) + .invoke(handler, level, proxy); + } catch (ReflectiveOperationException ignored) { + } + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + if (type == byte.class) { + return (byte) 0; + } + if (type == short.class) { + return (short) 0; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + if (type == float.class) { + return 0F; + } + return 0D; + } + + private record Key(UUID nodeId, boolean transmit) {} + + private static final class Actor { + private final String signature; + private Object proxy; + private int transmitted; + private int received; + + private Actor(String signature) { + this.signature = signature; + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/ApiBackedWNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/ApiBackedWNode.java deleted file mode 100644 index 800e6f1..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/ApiBackedWNode.java +++ /dev/null @@ -1,401 +0,0 @@ -package dev.propulsionteam.computed.internal.node; - -import com.mojang.serialization.Codec; -import dev.propulsionteam.computed.Computed; -import dev.propulsionteam.computed.api.node.DiagnosticSink; -import dev.propulsionteam.computed.api.node.ExecutionPolicy; -import dev.propulsionteam.computed.api.node.NodeDiagnostic; -import dev.propulsionteam.computed.api.node.NodeExecutionContext; -import dev.propulsionteam.computed.api.node.NodeProperty; -import dev.propulsionteam.computed.api.node.NodePropertyBag; -import dev.propulsionteam.computed.api.node.NodeSchema; -import dev.propulsionteam.computed.api.node.NodeType; -import dev.propulsionteam.computed.api.node.PortDefinition; -import dev.propulsionteam.computed.api.node.PortKey; -import dev.propulsionteam.computed.api.node.PortType; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WCheckbox; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.minecraft.core.BlockPos; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtOps; -import net.minecraft.nbt.Tag; -import net.minecraft.server.level.ServerLevel; - -/** Runtime bridge for node types registered through the clean public API. */ -final class ApiBackedWNode extends WNode { - private static final String STATE_TAG = "ComputedApiState"; - private static final String PROPERTIES_TAG = "ComputedApiProperties"; - - private final NodeType type; - private final Map, Integer> inputIndexes = new LinkedHashMap<>(); - private final Map, Integer> outputIndexes = new LinkedHashMap<>(); - private final Map, WElement> propertyControls = new LinkedHashMap<>(); - private NodePropertyBag properties; - private Object state; - private List> schemaSignature = List.of(); - - @SuppressWarnings("unchecked") - ApiBackedWNode(NodeType type, int x, int y) { - super(type.id(), type.title().getString(), x, y); - this.type = (NodeType) Objects.requireNonNull(type, "type"); - properties = type.defaultProperties(); - state = this.type.defaultState(); - buildGenericPropertyControls(); - rebuildSchema(); - setEvaluator(ignored -> evaluatePublicNode()); - } - - private void rebuildSchema() { - inputIndexes.clear(); - outputIndexes.clear(); - getInputs().clear(); - getOutputs().clear(); - markPinSchemaChanged(); - NodeSchema schema = type.schema(properties); - schemaSignature = schema.ports(); - for (PortDefinition definition : schema.inputs()) { - inputIndexes.put(definition.key(), inputIndexes.size()); - addInput( - definition.key().id(), - definition.label().getString(), - legacyType(definition.key().type()), - definition.key().type().defaultColor()); - } - for (PortDefinition definition : schema.outputs()) { - outputIndexes.put(definition.key(), outputIndexes.size()); - addOutput( - definition.key().id(), - definition.label().getString(), - legacyType(definition.key().type()), - definition.key().type().defaultColor()); - } - updateLayout(); - } - - private void buildGenericPropertyControls() { - for (NodeProperty property : type.properties()) { - if (property.valueClass() == Boolean.class) { - WCheckbox checkbox = new WCheckbox(property.title().getString()); - checkbox.setChecked((Boolean) properties.values().get(property.key())); - propertyControls.put(property, checkbox); - addElement(checkbox); - } else if (property.valueClass() == String.class - || property.valueClass() == Double.class - || property.valueClass() == Integer.class) { - addElement(new WLabel(property.title().getString(), 0xFFAAAAAA)); - WTextField field = new WTextField(88); - field.setValue(String.valueOf(properties.values().get(property.key()))); - propertyControls.put(property, field); - addElement(field); - } else { - addElement(new WLabel(property.title().getString() + " (custom)", 0xFFFFAA55)); - } - } - } - - private void syncPropertiesFromControls() { - NodePropertyBag next = properties; - for (Map.Entry, WElement> entry : propertyControls.entrySet()) { - NodeProperty property = entry.getKey(); - try { - Object value; - if (entry.getValue() instanceof WCheckbox checkbox) { - value = checkbox.isChecked(); - } else if (entry.getValue() instanceof WTextField field) { - String raw = field.getValue().trim(); - if (property.valueClass() == String.class) value = raw; - else if (property.valueClass() == Integer.class) value = Integer.parseInt(raw); - else value = Double.parseDouble(raw.replace(',', '.')); - } else { - continue; - } - next = withUnchecked(next, property, value); - } catch (IllegalArgumentException ignored) { - // Keep the last valid value while the user is typing or validation rejects the edit. - } - } - if (!next.values().equals(properties.values())) { - properties = next; - NodeSchema schema = type.schema(properties); - if (!schema.ports().equals(schemaSignature)) rebuildSchema(); - } - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private static NodePropertyBag withUnchecked(NodePropertyBag bag, NodeProperty property, Object value) { - return bag.with(property, value); - } - - private void updateControlsFromProperties() { - for (Map.Entry, WElement> entry : propertyControls.entrySet()) { - Object value = properties.values().get(entry.getKey().key()); - if (entry.getValue() instanceof WCheckbox checkbox && value instanceof Boolean bool) { - checkbox.setChecked(bool); - } else if (entry.getValue() instanceof WTextField field && value != null) { - field.setValue(String.valueOf(value)); - } - } - } - - private void evaluatePublicNode() { - syncPropertiesFromControls(); - try { - Object nextState = type.evaluator().execute(state, new Context()); - state = Objects.requireNonNull(nextState, "Node executor returned null state"); - } catch (Exception exception) { - resetOutputs(); - Computed.LOGGER.error("Public node {} ({}) failed to evaluate", type.id(), getId(), exception); - } - } - - private void resetOutputs() { - for (WPin pin : getOutputs()) { - switch (pin.getDataType()) { - case NUMBER -> pin.setValue(0.0); - case STRING -> pin.setStringValue(""); - case WIDGET -> pin.setWidgetValue(null); - } - } - } - - @Override - public boolean isStateBoundary() { - return type.stateBoundary(); - } - - @Override - public ExecutionPolicy executionPolicy() { - return type.executionPolicy(); - } - - @Override - public CompoundTag save() { - syncPropertiesFromControls(); - CompoundTag tag = super.save(); - encode(type.stateCodec(), state).ifPresent(encoded -> tag.put(STATE_TAG, encoded)); - CompoundTag propertyTag = new CompoundTag(); - for (NodeProperty property : type.properties()) { - encodeProperty(property, properties, propertyTag); - } - if (!propertyTag.isEmpty()) { - tag.put(PROPERTIES_TAG, propertyTag); - } - return tag; - } - - @Override - public void load(CompoundTag tag) { - if (tag.contains(PROPERTIES_TAG, Tag.TAG_COMPOUND)) { - NodePropertyBag.Builder builder = NodePropertyBag.builder(type.properties()); - CompoundTag propertyTag = tag.getCompound(PROPERTIES_TAG); - for (NodeProperty property : type.properties()) { - decodeProperty(property, propertyTag, builder); - } - properties = builder.build(); - rebuildSchema(); - } - super.load(tag); - updateControlsFromProperties(); - if (tag.contains(STATE_TAG)) { - type.stateCodec() - .parse(NbtOps.INSTANCE, tag.get(STATE_TAG)) - .resultOrPartial(message -> Computed.LOGGER.warn( - "Could not decode state for public node {}: {}", type.id(), message)) - .ifPresent(decoded -> state = decoded); - } - } - - @Override - public void render( - net.minecraft.client.gui.GuiGraphics graphics, - int mouseX, - int mouseY, - float partialTick) { - syncPropertiesFromControls(); - super.render(graphics, mouseX, mouseY, partialTick); - if (net.neoforged.fml.loading.FMLEnvironment.dist == net.neoforged.api.distmarker.Dist.CLIENT) { - dev.propulsionteam.computed.api.node.client.ComputedNodeClientApi.presentation(type.id()) - .ifPresent(presentation -> presentation.render( - new PresentationContext(graphics, mouseX, mouseY, partialTick))); - } - } - - private static Optional encode(Codec codec, Object value) { - return codec.encodeStart(NbtOps.INSTANCE, value) - .resultOrPartial(message -> Computed.LOGGER.warn("Could not encode public node state: {}", message)); - } - - private static void encodeProperty( - NodeProperty property, NodePropertyBag bag, CompoundTag target) { - property.codec() - .encodeStart(NbtOps.INSTANCE, bag.get(property)) - .resultOrPartial(message -> Computed.LOGGER.warn( - "Could not encode public node property {}: {}", property.key(), message)) - .ifPresent(value -> target.put(property.key(), value)); - } - - private static void decodeProperty( - NodeProperty property, CompoundTag source, NodePropertyBag.Builder target) { - if (!source.contains(property.key())) { - return; - } - property.codec() - .parse(NbtOps.INSTANCE, source.get(property.key())) - .resultOrPartial(message -> Computed.LOGGER.warn( - "Could not decode public node property {}: {}", property.key(), message)) - .filter(property::isValid) - .ifPresent(value -> target.set(property, value)); - } - - private static WPin.DataType legacyType(PortType type) { - if (type == PortType.NUMBER) return WPin.DataType.NUMBER; - if (type == PortType.STRING) return WPin.DataType.STRING; - return WPin.DataType.WIDGET; - } - - private final class Context implements NodeExecutionContext { - @Override - public NodePropertyBag properties() { - return properties; - } - - @Override - public T input(PortKey key) { - Integer index = inputIndexes.get(key); - if (index == null) { - throw new IllegalArgumentException("Unknown input port " + key + " on " + type.id()); - } - WPin pin = getInputs().get(index); - Object value = switch (pin.getDataType()) { - case NUMBER -> pin.getValue(); - case STRING -> pin.getStringValue(); - case WIDGET -> pin.getWidgetValue(); - }; - return key.type().castOrDefault(value); - } - - @Override - public void output(PortKey key, T value) { - Integer index = outputIndexes.get(key); - if (index == null) { - throw new IllegalArgumentException("Unknown output port " + key + " on " + type.id()); - } - if (!key.type().accepts(value)) { - throw new IllegalArgumentException("Output " + key + " received an incompatible value"); - } - WPin pin = getOutputs().get(index); - if (key.type() == PortType.NUMBER) { - pin.setValue((Double) value); - } else if (key.type() == PortType.STRING) { - pin.setStringValue((String) value); - } else { - pin.setWidgetValue(value); - } - } - - @Override - public boolean isInputConnected(PortKey key) { - Integer index = inputIndexes.get(key); - return index != null && getInputs().get(index).isConnected(); - } - - @Override - public long gameTick() { - return level().map(ServerLevel::getGameTime).orElse(0L); - } - - @Override - public long graphStep() { - return evaluationGraph() == null ? 0L : evaluationGraph().getSimulationStepCounter(); - } - - @Override - public boolean isPreview() { - return level().isEmpty(); - } - - @Override - public Optional level() { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - return host != null && host.getLevel() instanceof ServerLevel serverLevel - ? Optional.of(serverLevel) - : Optional.empty(); - } - - @Override - public Optional origin() { - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - return host == null ? Optional.empty() : Optional.of(host.getBlockPos()); - } - - @Override - public boolean sideEffectsAllowed() { - return level().isPresent(); - } - - @Override - public DiagnosticSink diagnostics() { - return diagnostic -> logDiagnostic(diagnostic.forNode(getId())); - } - - private void logDiagnostic(NodeDiagnostic diagnostic) { - switch (diagnostic.severity()) { - case INFO -> Computed.LOGGER.info("Node {}: {}", type.id(), diagnostic.message().getString()); - case WARNING -> Computed.LOGGER.warn("Node {}: {}", type.id(), diagnostic.message().getString()); - case ERROR -> Computed.LOGGER.error("Node {}: {}", type.id(), diagnostic.message().getString()); - } - } - } - - private final class PresentationContext - implements dev.propulsionteam.computed.api.node.client.NodePresentationContext { - private final net.minecraft.client.gui.GuiGraphics graphics; - private final int mouseX; - private final int mouseY; - private final float partialTick; - - private PresentationContext( - net.minecraft.client.gui.GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - this.graphics = graphics; - this.mouseX = mouseX; - this.mouseY = mouseY; - this.partialTick = partialTick; - } - - @Override public java.util.UUID nodeId() { return getId(); } - @Override public NodeType nodeType() { return type; } - @Override public NodePropertyBag properties() { return properties; } - @Override public net.minecraft.client.gui.GuiGraphics graphics() { return graphics; } - @Override public int x() { return getX(); } - @Override public int y() { return getY() + 16; } - @Override public int width() { return getWidth(); } - @Override public int height() { return Math.max(0, getHeight() - 16); } - @Override public int mouseX() { return mouseX; } - @Override public int mouseY() { return mouseY; } - @Override public float partialTick() { return partialTick; } - - @Override - public void setProperty(NodeProperty property, T value) { - properties = properties.with(property, value); - updateControlsFromProperties(); - NodeSchema schema = type.schema(properties); - if (!schema.ports().equals(schemaSignature)) rebuildSchema(); - } - - @Override - public void renderGenericPropertyControls() { - // Standard WElements were already rendered by the base node immediately before this hook. - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/ComputedNodeSystem.java b/src/main/java/dev/propulsionteam/computed/internal/node/ComputedNodeSystem.java deleted file mode 100644 index 1a13838..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/ComputedNodeSystem.java +++ /dev/null @@ -1,94 +0,0 @@ -package dev.propulsionteam.computed.internal.node; - -import dev.propulsionteam.computed.api.node.ComputedNodeApi; -import dev.propulsionteam.computed.api.node.NodeCategory; -import dev.propulsionteam.computed.api.node.NodeType; -import dev.propulsionteam.computed.internal.node.api.InternalNodeTypeAdapter; -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import java.util.HashMap; -import java.util.Map; -import net.minecraft.resources.ResourceLocation; - -/** Computed's built-in node system. Derived portions retain the upstream MIT attribution. */ -public final class ComputedNodeSystem { - private ComputedNodeSystem() {} - - public static void bootstrap() { - dev.propulsionteam.computed.internal.node.internal.BuiltinNodes.register(); - } - - /** Bridges built-ins and third-party public API registrations, then freezes the public registry. */ - public static void finalizeRegistrations() { - publishInternalCategories(); - Map paletteCategories = new HashMap<>(); - for (NodeMenuRegistry.MenuEntry entry : NodeMenuRegistry.getExplicitEntries()) { - paletteCategories.putIfAbsent(entry.nodeType(), publicCategory(entry.categoryId())); - } - - for (Map.Entry entry : - NodeRegistry.getRegistry().entrySet()) { - ResourceLocation id = entry.getKey(); - if (ComputedNodeApi.nodeType(id).isPresent()) { - throw new IllegalStateException( - "Public node type " + id + " conflicts with a Computed built-in or JSON node"); - } - WNode sample = entry.getValue().create(0, 0); - ComputedNodeApi.register(InternalNodeTypeAdapter.describe( - entry.getValue(), - sample, - paletteCategories.getOrDefault(id, ComputedNodeApi.UNCATEGORIZED_CATEGORY))); - } - - publishPublicCategoriesToEditor(); - for (NodeType type : ComputedNodeApi.nodeTypes().values()) { - if (NodeRegistry.isRegistered(type.id())) { - continue; - } - NodeRegistry.register(type.id(), (x, y) -> new ApiBackedWNode(type, x, y)); - NodeMenuRegistry.addNodeEntry(internalCategory(type.category()), type.id(), type.title()); - } - ComputedNodeApi.freeze(); - } - - private static void publishInternalCategories() { - for (NodeMenuRegistry.Category category : NodeMenuRegistry.getCategories()) { - ResourceLocation publicId = publicCategory(category.id()); - if (ComputedNodeApi.category(publicId).isPresent()) { - continue; - } - ComputedNodeApi.registerCategory(NodeCategory.child( - publicId, - category.title(), - publicCategory(category.parentId()))); - } - } - - private static void publishPublicCategoriesToEditor() { - for (NodeCategory category : ComputedNodeApi.categories().values()) { - if (category.id().equals(ComputedNodeApi.ROOT_CATEGORY) - || category.id().equals(ComputedNodeApi.UNCATEGORIZED_CATEGORY) - || NodeMenuRegistry.getCategory(category.id()) != null) { - continue; - } - ResourceLocation parent = category.parentId() - .map(ComputedNodeSystem::internalCategory) - .orElse(NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(category.id(), category.title(), parent); - } - } - - private static ResourceLocation publicCategory(ResourceLocation internal) { - if (internal.equals(NodeMenuRegistry.ROOT)) return ComputedNodeApi.ROOT_CATEGORY; - if (internal.equals(NodeMenuRegistry.UNCATEGORIZED)) return ComputedNodeApi.UNCATEGORIZED_CATEGORY; - return internal; - } - - private static ResourceLocation internalCategory(ResourceLocation publicId) { - if (publicId.equals(ComputedNodeApi.ROOT_CATEGORY)) return NodeMenuRegistry.ROOT; - if (publicId.equals(ComputedNodeApi.UNCATEGORIZED_CATEGORY)) return NodeMenuRegistry.UNCATEGORIZED; - return publicId; - } - -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/MissingNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/MissingNode.java deleted file mode 100644 index 536a2c0..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/MissingNode.java +++ /dev/null @@ -1,86 +0,0 @@ -package dev.propulsionteam.computed.internal.node; - -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WPin; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.Tag; -import net.minecraft.resources.ResourceLocation; - -/** Lossless editor placeholder used when the node implementation is supplied by a missing addon. */ -public final class MissingNode extends WNode { - public static final String MISSING_MARKER = "ComputedMissingType"; - private CompoundTag rawTag; - - private MissingNode(ResourceLocation originalType, int x, int y, CompoundTag rawTag) { - super(originalType, "Missing: " + originalType, x, y); - this.rawTag = rawTag.copy(); - addPins(rawTag.getList("inputs", Tag.TAG_COMPOUND), true); - addPins(rawTag.getList("outputs", Tag.TAG_COMPOUND), false); - addElement(new WLabel("Missing addon node", 0xFFFF5555)); - addElement(new WLabel(originalType.toString(), 0xFFFFAA88)); - setEvaluator(ignored -> resetOutputs()); - } - - public static MissingNode fromLegacyTag(ResourceLocation originalType, CompoundTag rawTag) { - return new MissingNode(originalType, rawTag.getInt("x"), rawTag.getInt("y"), rawTag); - } - - private void addPins(ListTag tags, boolean input) { - for (int index = 0; index < tags.size(); index++) { - CompoundTag pinTag = tags.getCompound(index); - WPin.DataType type = parseType(pinTag); - String label = pinTag.contains("name", Tag.TAG_STRING) - ? pinTag.getString("name") - : (input ? "Input " : "Output ") + (index + 1); - if (input) addInput(label, type, 0xFF888888); - else addOutput(label, type, 0xFF888888); - } - } - - private static WPin.DataType parseType(CompoundTag tag) { - if (tag.contains("dataType", Tag.TAG_STRING)) { - try { - return WPin.DataType.valueOf(tag.getString("dataType").toUpperCase(java.util.Locale.ROOT)); - } catch (IllegalArgumentException ignored) { - } - } - if (tag.contains("s", Tag.TAG_STRING)) return WPin.DataType.STRING; - if (tag.contains("value")) return WPin.DataType.NUMBER; - return WPin.DataType.WIDGET; - } - - private void resetOutputs() { - for (WPin pin : getOutputs()) { - switch (pin.getDataType()) { - case NUMBER -> pin.setValue(0.0); - case STRING -> pin.setStringValue(""); - case WIDGET -> pin.setWidgetValue(null); - } - } - } - - @Override - public boolean isMissingType() { - return true; - } - - @Override - public CompoundTag save() { - CompoundTag result = rawTag.copy(); - CompoundTag structural = super.save(); - for (String key : structural.getAllKeys()) { - Tag value = structural.get(key); - if (value != null) result.put(key, value.copy()); - } - result.putBoolean(MISSING_MARKER, true); - return result; - } - - @Override - public void load(CompoundTag tag) { - rawTag = tag.copy(); - super.load(tag); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/ProgramBridge.java b/src/main/java/dev/propulsionteam/computed/internal/node/ProgramBridge.java deleted file mode 100644 index ecf32c1..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/ProgramBridge.java +++ /dev/null @@ -1,362 +0,0 @@ -package dev.propulsionteam.computed.internal.node; - -import dev.propulsionteam.computed.api.node.ComputedNodeApi; -import dev.propulsionteam.computed.internal.node.api.FunctionCardNode; -import dev.propulsionteam.computed.internal.node.api.FunctionDefinitionStore; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.node.program.ComputedProgram; -import dev.propulsionteam.computed.node.program.ConnectionModel; -import dev.propulsionteam.computed.node.program.FunctionModel; -import dev.propulsionteam.computed.node.program.GraphModel; -import dev.propulsionteam.computed.node.program.NodeModel; -import dev.propulsionteam.computed.node.program.PortModel; -import dev.propulsionteam.computed.node.program.SectionModel; -import dev.propulsionteam.computed.node.program.ProgramCodec; -import dev.propulsionteam.computed.node.runtime.GraphAnalysis; -import java.util.ArrayList; -import java.util.HashMap; -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; -import java.util.function.Predicate; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.Tag; -import net.minecraft.resources.ResourceLocation; - -/** - * Narrow compatibility boundary between the v2 persistent model and the editor/runtime that is - * being retired. Nothing outside Computed internals should depend on WGraph. - */ -public final class ProgramBridge { - public static final String PROGRAM_TAG = "ComputedProgram"; - - private ProgramBridge() {} - - public record RuntimeProgram( - ComputedProgram program, - WGraph graph, - FunctionDefinitionStore functions, - boolean migrated) {} - - public static RuntimeProgram decode(CompoundTag source) { - Objects.requireNonNull(source, "source"); - ProgramCodec.DecodeResult decoded = ProgramCodec.decode(source, ProgramBridge::isKnownNodeType); - CompoundTag bundle = ProgramCodec.toLegacyBundleTag(decoded.program()); - WGraph graph = new WGraph(); - graph.load(bundle.getCompound("ComputerGraph")); - FunctionDefinitionStore functions = new FunctionDefinitionStore(); - functions.load(bundle.getList("ComputerFunctions", Tag.TAG_COMPOUND)); - FunctionCardNode.applyLibraryToInnerGraphs(graph, functions); - return new RuntimeProgram(decoded.program(), graph, functions, decoded.migrated()); - } - - public static ComputedProgram snapshot( - WGraph graph, FunctionDefinitionStore functions, long revision) { - Objects.requireNonNull(graph, "graph"); - FunctionDefinitionStore library = functions == null ? new FunctionDefinitionStore() : functions; - library.syncBodiesFromGraph(graph); - CompoundTag bundle = new CompoundTag(); - bundle.put("ComputerGraph", graph.save()); - bundle.put("ComputerFunctions", library.saveList()); - return ProgramCodec.decode(bundle, ProgramBridge::isKnownNodeType) - .program() - .withRevision(revision); - } - - /** - * Applies an editor/runtime snapshot without discarding raw addon data or connections that the - * transitional UI cannot represent. Valid connections absent from the snapshot stay deleted. - */ - public static ComputedProgram reconcile(ComputedProgram base, ComputedProgram snapshot) { - if (base == null) return snapshot; - GraphModel root = reconcileGraph(base.rootGraph(), snapshot.rootGraph()); - Map baseFunctions = new HashMap<>(); - for (FunctionModel function : base.functions()) baseFunctions.put(function.id(), function); - List functions = new ArrayList<>(); - for (FunctionModel current : snapshot.functions()) { - FunctionModel previous = baseFunctions.get(current.id()); - functions.add(previous == null - ? current - : new FunctionModel( - current.id(), - current.name(), - reconcileGraph(previous.graph(), current.graph()), - mergeTags(previous.metadata(), current.metadata()), - mergeTags(previous.rawTag(), current.rawTag()))); - } - return new ComputedProgram( - snapshot.revision(), - root, - functions, - mergeDiagnostics(base, snapshot), - mergeTags(base.metadata(), snapshot.metadata())); - } - - /** - * Keeps runtime state authoritative when an editor saves an older structural snapshot. Matching - * node ids and types retain the state captured on the server; new or replaced nodes keep the - * incoming state. Function bodies are matched by stable function id. - */ - public static ComputedProgram preserveRuntimeState(ComputedProgram incoming, ComputedProgram authoritative) { - Objects.requireNonNull(incoming, "incoming"); - Objects.requireNonNull(authoritative, "authoritative"); - Map authoritativeFunctions = new HashMap<>(); - for (FunctionModel function : authoritative.functions()) { - authoritativeFunctions.put(function.id(), function); - } - List functions = new ArrayList<>(incoming.functions().size()); - for (FunctionModel function : incoming.functions()) { - FunctionModel current = authoritativeFunctions.get(function.id()); - functions.add(current == null - ? function - : new FunctionModel( - function.id(), - function.name(), - preserveGraphRuntimeState(function.graph(), current.graph()), - function.metadata(), - function.rawTag())); - } - return new ComputedProgram( - incoming.revision(), - preserveGraphRuntimeState(incoming.rootGraph(), authoritative.rootGraph()), - functions, - incoming.diagnostics(), - incoming.metadata()); - } - - private static GraphModel preserveGraphRuntimeState(GraphModel incoming, GraphModel authoritative) { - Map authoritativeNodes = new HashMap<>(); - for (NodeModel node : authoritative.nodes()) authoritativeNodes.put(node.id(), node); - List nodes = new ArrayList<>(incoming.nodes().size()); - for (NodeModel node : incoming.nodes()) { - NodeModel current = authoritativeNodes.get(node.id()); - if (current == null || !current.typeId().equals(node.typeId())) { - nodes.add(node); - continue; - } - CompoundTag state = current.state(); - CompoundTag incomingState = node.state(); - // Function-card bodies are editable program structure, not server-owned temporal state. - if (incomingState.contains("innerGraph", Tag.TAG_COMPOUND)) { - state.put("innerGraph", incomingState.getCompound("innerGraph").copy()); - } else { - state.remove("innerGraph"); - } - nodes.add(new NodeModel( - node.id(), - node.typeId(), - node.originalTypeId(), - node.title(), - node.x(), - node.y(), - node.properties(), - state, - node.ports(), - node.placeholderStatus(), - node.rawTag())); - } - return new GraphModel( - incoming.id(), - nodes, - incoming.connections(), - incoming.sections(), - incoming.metadata(), - incoming.rawTag()); - } - - private static GraphModel reconcileGraph(GraphModel base, GraphModel current) { - Map baseNodes = new HashMap<>(); - for (NodeModel node : base.nodes()) baseNodes.put(node.id(), node); - List nodes = new ArrayList<>(); - for (NodeModel node : current.nodes()) { - NodeModel previous = baseNodes.get(node.id()); - nodes.add(previous == null - ? node - : new NodeModel( - node.id(), - node.typeId(), - previous.originalTypeId(), - node.title(), - node.x(), - node.y(), - mergeTags(previous.properties(), node.properties()), - mergeTags(previous.state(), node.state()), - node.ports(), - node.placeholderStatus(), - mergeTags(previous.rawTag(), node.rawTag()))); - } - - Map> previousByIdentity = new LinkedHashMap<>(); - for (ConnectionModel connection : base.connections()) { - previousByIdentity.computeIfAbsent(ConnectionIdentity.of(connection), ignored -> new ArrayList<>()) - .add(connection); - } - Set consumedPrevious = new HashSet<>(); - List connections = new ArrayList<>(); - for (ConnectionModel connection : current.connections()) { - List candidates = previousByIdentity.get(ConnectionIdentity.of(connection)); - ConnectionModel previous = candidates == null - ? null - : candidates.stream().filter(candidate -> !consumedPrevious.contains(candidate.id())).findFirst().orElse(null); - if (previous == null) { - connections.add(connection); - } else { - consumedPrevious.add(previous.id()); - connections.add(new ConnectionModel( - previous.id(), - connection.sourceNode(), - connection.sourcePort(), - connection.targetNode(), - connection.targetPort(), - connection.waypoints(), - mergeTags(previous.rawTag(), connection.rawTag()))); - } - } - for (ConnectionModel previous : base.connections()) { - if (!consumedPrevious.contains(previous.id()) && structurallyUnrepresentable(base, previous)) { - connections.add(previous); - } - } - - Map baseSections = new HashMap<>(); - for (SectionModel section : base.sections()) baseSections.put(section.id(), section); - List sections = new ArrayList<>(); - for (SectionModel section : current.sections()) { - SectionModel previous = baseSections.get(section.id()); - sections.add(previous == null - ? section - : new SectionModel( - section.id(), - section.name(), - section.x(), - section.y(), - section.width(), - section.height(), - section.bodyColorArgb(), - section.layer(), - mergeTags(previous.rawTag(), section.rawTag()))); - } - return new GraphModel( - base.id(), - nodes, - connections, - sections, - mergeTags(base.metadata(), current.metadata()), - mergeTags(base.rawTag(), current.rawTag())); - } - - private static boolean structurallyUnrepresentable(GraphModel graph, ConnectionModel connection) { - NodeModel source = graph.node(connection.sourceNode()).orElse(null); - NodeModel target = graph.node(connection.targetNode()).orElse(null); - if (source == null || target == null) return true; - PortModel sourcePort = source.port(connection.sourcePort(), PortModel.Direction.OUTPUT).orElse(null); - PortModel targetPort = target.port(connection.targetPort(), PortModel.Direction.INPUT).orElse(null); - if (sourcePort == null || targetPort == null) return true; - return !GraphAnalysis.compatibleValueTypes(sourcePort.valueType(), targetPort.valueType()); - } - - private static List mergeDiagnostics( - ComputedProgram base, ComputedProgram current) { - var merged = new LinkedHashMap(); - for (var diagnostic : base.diagnostics()) merged.put(diagnosticKey(diagnostic), diagnostic); - for (var diagnostic : current.diagnostics()) merged.put(diagnosticKey(diagnostic), diagnostic); - return List.copyOf(merged.values()); - } - - private static String diagnosticKey(dev.propulsionteam.computed.node.program.ProgramDiagnostic diagnostic) { - return diagnostic.code() + '|' + diagnostic.graphId() + '|' + diagnostic.nodeId() + '|' - + diagnostic.connectionId() + '|' + diagnostic.message(); - } - - private static CompoundTag mergeTags(CompoundTag base, CompoundTag current) { - CompoundTag merged = base == null ? new CompoundTag() : base.copy(); - if (current != null) { - for (String key : current.getAllKeys()) { - Tag value = current.get(key); - if (value != null) merged.put(key, value.copy()); - } - } - return merged; - } - - private record ConnectionIdentity( - UUID sourceNode, - String sourcePort, - UUID targetNode, - String targetPort) { - static ConnectionIdentity of(ConnectionModel connection) { - return new ConnectionIdentity( - connection.sourceNode(), - connection.sourcePort().value(), - connection.targetNode(), - connection.targetPort().value()); - } - } - - public static CompoundTag writeEnvelope(ComputedProgram program) { - CompoundTag envelope = new CompoundTag(); - envelope.put(PROGRAM_TAG, ProgramCodec.write(program)); - return envelope; - } - - public static boolean containsProgram(CompoundTag source) { - return source.contains(PROGRAM_TAG, Tag.TAG_COMPOUND) - || source.contains("formatVersion") - || source.contains("ComputerGraph", Tag.TAG_COMPOUND) - || source.contains("nodes", Tag.TAG_LIST) - || source.contains("graph", Tag.TAG_COMPOUND); - } - - public static boolean isKnownNodeType(String raw) { - try { - ResourceLocation id = NodeRegistry.canonicalize(ResourceLocation.parse(raw)); - return NodeRegistry.isRegistered(id) || ComputedNodeApi.nodeType(id).isPresent(); - } catch (RuntimeException ignored) { - return false; - } - } - - public static boolean isStateBoundaryType(String raw) { - try { - ResourceLocation id = NodeRegistry.canonicalize(ResourceLocation.parse(raw)); - var publicType = ComputedNodeApi.nodeType(id); - if (publicType.isPresent()) return publicType.get().stateBoundary(); - WNode node = NodeRegistry.createNode(id, 0, 0); - return node != null && node.isStateBoundary(); - } catch (RuntimeException ignored) { - return false; - } - } - - public static GraphAnalysis.AnalysisResult analyze(ComputedProgram program) { - return GraphAnalysis.analyze(program.rootGraph(), ProgramBridge::isStateBoundaryType); - } - - /** Analyzes the root and every saved function graph for transactional server validation. */ - public static List analyzeAll(ComputedProgram program) { - Objects.requireNonNull(program, "program"); - List results = new ArrayList<>(program.functions().size() + 1); - results.add(analyzeGraph(program.rootGraph())); - for (FunctionModel function : program.functions()) { - results.add(analyzeGraph(function.graph())); - } - return List.copyOf(results); - } - - private static AnalyzedGraph analyzeGraph(GraphModel graph) { - return new AnalyzedGraph(graph.id(), GraphAnalysis.analyze(graph, ProgramBridge::isStateBoundaryType)); - } - - public record AnalyzedGraph(UUID graphId, GraphAnalysis.AnalysisResult analysis) { - public AnalyzedGraph { - Objects.requireNonNull(graphId, "graphId"); - Objects.requireNonNull(analysis, "analysis"); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/CounterNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/CounterNode.java deleted file mode 100644 index c4e9dd4..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/CounterNode.java +++ /dev/null @@ -1,145 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import dev.propulsionteam.computed.internal.node.api.elements.WButton; -import dev.propulsionteam.computed.internal.node.api.elements.WCheckbox; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -/** - * Counts on {@code Step} rising edges, optional {@code Reset} to min, optional {@code Auto} on each graph tick - * pulse (same gating as the Tick node in world mode). Output is clamped between Min and Max from the number - * fields. - */ -public final class CounterNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath("computed", "counter"); - - private double count; - private boolean prevStepHigh; - private boolean prevResetHigh; - - private final WCheckbox autoOnTickPulse; - private final WTextField minField; - private final WTextField maxField; - private final WLabel countLabel; - - public CounterNode(int x, int y) { - super(TYPE_ID, "Counter", x, y); - addInput("Step", 0xFF00FF88); - addInput("Reset", 0xFFFF6666); - addOutput("Count", 0xFFFFBB00); - - addElement(new WLabel("Step / Reset: rising >0.5")); - autoOnTickPulse = new WCheckbox("Auto (+1 on tick pulse)"); - addElement(autoOnTickPulse); - addElement(new WLabel("Min")); - minField = new WTextField(72); - minField.setValue("0"); - addElement(minField); - addElement(new WLabel("Max")); - maxField = new WTextField(72); - maxField.setValue("1000000"); - addElement(maxField); - countLabel = new WLabel("0", 0xFFFFFF00); - addElement(countLabel); - addElement(new WButton("+1", 56, () -> { - bumpTowardMax(); - refreshCountLabel(); - })); - - setEvaluator(this::evaluateSelf); - refreshCountLabel(); - } - - private void evaluateSelf(WNode n) { - double lo = Math.min(parseDouble(minField, 0.0), parseDouble(maxField, 1_000_000.0)); - double hi = Math.max(parseDouble(minField, 0.0), parseDouble(maxField, 1_000_000.0)); - - boolean stepHigh = n.getInputs().size() > 0 && n.getInputs().get(0).getValue() > 0.5; - boolean resetHigh = n.getInputs().size() > 1 && n.getInputs().get(1).getValue() > 0.5; - boolean stepRise = stepHigh && !prevStepHigh; - boolean resetRise = resetHigh && !prevResetHigh; - prevStepHigh = stepHigh; - prevResetHigh = resetHigh; - - if (resetRise) { - count = lo; - } else if (stepRise) { - count = Mth.clamp(count + 1.0, lo, hi); - } else if (autoOnTickPulse.isChecked()) { - WGraph g = n.evaluationGraph(); - if (g != null && g.isEvalTickPulseGate()) { - count = Mth.clamp(count + 1.0, lo, hi); - } - } - - count = Mth.clamp(count, lo, hi); - n.getOutputs().get(0).setValue(count); - refreshCountLabel(); - } - - private void bumpTowardMax() { - double lo = Math.min(parseDouble(minField, 0.0), parseDouble(maxField, 1_000_000.0)); - double hi = Math.max(parseDouble(minField, 0.0), parseDouble(maxField, 1_000_000.0)); - count = Mth.clamp(count + 1.0, lo, hi); - getOutputs().get(0).setValue(count); - } - - private static double parseDouble(WTextField field, double fallback) { - try { - String s = field.getValue().trim().replace(',', '.'); - if (s.isEmpty()) { - return fallback; - } - return Double.parseDouble(s); - } catch (NumberFormatException e) { - return fallback; - } - } - - private void refreshCountLabel() { - if (Math.abs(count - Math.rint(count)) < 1e-9) { - countLabel.setText(String.valueOf((long) Math.rint(count))); - } else { - countLabel.setText(String.format("%.4g", count)); - } - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putDouble("Count", count); - tag.putBoolean("PrevStepHigh", prevStepHigh); - tag.putBoolean("PrevResetHigh", prevResetHigh); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - if (tag.contains("Count")) { - count = tag.getDouble("Count"); - } - prevStepHigh = tag.getBoolean("PrevStepHigh"); - prevResetHigh = tag.getBoolean("PrevResetHigh"); - getOutputs().get(0).setValue(count); - refreshCountLabel(); - } - - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Counter"); - - public static void register() { - NodeRegistry.register(TYPE_ID, CounterNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionCardNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionCardNode.java deleted file mode 100644 index 1f02be9..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionCardNode.java +++ /dev/null @@ -1,225 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import dev.propulsionteam.computed.internal.node.api.elements.WIconStrip; -import java.util.List; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; - -/** - * A placeable function card: inner graph is edited in-place; {@link #getFunctionId()} keys - * {@link FunctionDefinitionStore} for persistent copies on the computer. - */ -public class FunctionCardNode extends WNode { - - public static final ResourceLocation TYPE_FUNCTION_CARD = - ResourceLocation.fromNamespaceAndPath("computed", "function_card"); - - private static final int MAX_EVAL_DEPTH = 48; - private static final ThreadLocal EVAL_DEPTH = ThreadLocal.withInitial(() -> 0); - - private static final ResourceLocation ICON_UI_CLICK = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/click.png"); - - private final WGraph innerGraph = new WGraph(); - private UUID functionId = UUID.randomUUID(); - - public FunctionCardNode(int x, int y) { - this(x, y, UUID.randomUUID()); - } - - public FunctionCardNode(int x, int y, UUID functionId) { - super(TYPE_FUNCTION_CARD, "Function", x, y); - this.functionId = functionId; - addElement(new WIconStrip( - List.of(UiKeyTextures.key("alt"), ICON_UI_CLICK), ": open", 0xFFCCCCCC, 12)); - innerGraph.updateTopology(); - setEvaluator(this::evaluateInner); - syncPinsFromInner(null); - } - - public static CompoundTag newInnerTemplateTag() { - WGraph g = new WGraph(); - WNode s = NodeRegistry.createNode(FunctionStartNode.TYPE_FN_START, -160, 0); - WNode e = NodeRegistry.createNode(FunctionEndNode.TYPE_FN_END, 160, 0); - if (s != null) { - g.addNode(s); - } - if (e != null) { - g.addNode(e); - } - g.updateTopology(); - return g.save(); - } - - public static FunctionCardNode createPlaced(int x, int y, UUID functionId, FunctionDefinitionStore store) { - FunctionCardNode card = new FunctionCardNode(x, y, functionId); - CompoundTag body = store.getBody(functionId); - if (body != null) { - card.getInnerGraph().load(body.copy()); - } else { - card.getInnerGraph().load(newInnerTemplateTag()); - } - card.syncPinsFromInner(store); - return card; - } - - /** - * After loading a root graph, refreshes each function card from the library store without clobbering - * in-graph state: the authoritative body is the {@code inner} tag on each card inside {@code ComputerGraph}. - * The store is only used to fill a missing/broken inner (old saves) or to refresh outer pins / titles. - */ - public static void applyLibraryToInnerGraphs(WGraph root, FunctionDefinitionStore store) { - if (store == null) { - return; - } - for (WNode n : root.getNodes()) { - if (n instanceof FunctionCardNode c) { - if (!functionInnerLooksUsable(c.getInnerGraph())) { - CompoundTag body = store.getBody(c.getFunctionId()); - if (body != null) { - c.getInnerGraph().load(body.copy()); - } - } - c.syncPinsFromInner(store); - } - } - } - - private static boolean functionInnerLooksUsable(WGraph g) { - return findStart(g) != null && findEnd(g) != null; - } - - public UUID getFunctionId() { - return functionId; - } - - public WGraph getInnerGraph() { - return innerGraph; - } - - /** - * Updates outer pins from Start/End nodes. When {@code store} is non-null, the card title is set to the - * library function name. - */ - public void syncPinsFromInner(FunctionDefinitionStore store) { - getInputs().clear(); - getOutputs().clear(); - markPinSchemaChanged(); - FunctionStartNode start = findStart(innerGraph); - if (start != null) { - start.syncPinsFromUiFields(); - for (WPin p : start.getOutputs()) { - addInput(p.getName(), p.getColor()); - } - } - FunctionEndNode end = findEnd(innerGraph); - if (end != null) { - end.syncPinsFromUiFields(); - } - if (end == null) { - addOutput("Out", 0xFFFFAA66); - applyTitleFromStore(store); - updateLayout(); - return; - } - if (end.isEmptyReturn()) { - applyTitleFromStore(store); - updateLayout(); - return; - } - for (WPin p : end.getInputs()) { - addOutput(p.getName(), p.getColor()); - } - applyTitleFromStore(store); - updateLayout(); - } - - /** @see #syncPinsFromInner(FunctionDefinitionStore) */ - public void syncPinsFromInner() { - syncPinsFromInner(null); - } - - private void applyTitleFromStore(FunctionDefinitionStore store) { - if (store == null) { - return; - } - FunctionDefinitionStore.Definition def = store.get(functionId); - if (def != null && def.name() != null && !def.name().isEmpty()) { - setTitle(def.name()); - } - } - - private static FunctionStartNode findStart(WGraph g) { - for (WNode n : g.getNodes()) { - if (n instanceof FunctionStartNode s) { - return s; - } - } - return null; - } - - private static FunctionEndNode findEnd(WGraph g) { - for (WNode n : g.getNodes()) { - if (n instanceof FunctionEndNode e) { - return e; - } - } - return null; - } - - private void evaluateInner(WNode self) { - int d = EVAL_DEPTH.get(); - if (d >= MAX_EVAL_DEPTH) { - return; - } - EVAL_DEPTH.set(d + 1); - try { - FunctionStartNode start = findStart(innerGraph); - FunctionEndNode end = findEnd(innerGraph); - if (start != null) { - int nIn = Math.min(self.getInputs().size(), start.getOutputs().size()); - for (int i = 0; i < nIn; i++) { - double v = self.getInputs().get(i).getValue(); - start.getOutputs().get(i).setValue(v); - } - } - innerGraph.propagateAndEvaluate(); - end = findEnd(innerGraph); - if (end != null && !end.isEmptyReturn()) { - int n = Math.min(self.getOutputs().size(), end.getInputs().size()); - for (int i = 0; i < n; i++) { - double v = end.getInputs().get(i).getValue(); - self.getOutputs().get(i).setValue(v); - } - } - } finally { - EVAL_DEPTH.set(d); - } - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putUUID("functionId", functionId); - tag.put("inner", innerGraph.save()); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - if (tag.hasUUID("functionId")) { - functionId = tag.getUUID("functionId"); - } - if (tag.contains("inner")) { - innerGraph.load(tag.getCompound("inner")); - } - syncPinsFromInner(null); - } - - public static void register() { - NodeRegistry.register(TYPE_FUNCTION_CARD, FunctionCardNode::new); - NodeMenuRegistry.hideFromAddMenu(TYPE_FUNCTION_CARD); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionDefinitionStore.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionDefinitionStore.java deleted file mode 100644 index 7c91a16..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionDefinitionStore.java +++ /dev/null @@ -1,92 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; - -/** - * Saved function definitions for a computer (name + inner graph body per id). Serialized as a list tag - * under {@code ComputerFunctions} alongside the root {@code ComputerGraph}. - */ -public final class FunctionDefinitionStore { - public record Definition(UUID id, String name, CompoundTag body) {} - - private final Map definitions = new LinkedHashMap<>(); - - public void clear() { - definitions.clear(); - } - - public boolean isEmpty() { - return definitions.isEmpty(); - } - - public int size() { - return definitions.size(); - } - - public Definition get(UUID id) { - return definitions.get(id); - } - - public CompoundTag getBody(UUID id) { - Definition d = definitions.get(id); - return d != null ? d.body() : null; - } - - public Collection definitionsInOrder() { - return definitions.values(); - } - - public void put(UUID id, String name, CompoundTag body) { - definitions.put(id, new Definition(id, name, body.copy())); - } - - /** Registers a new definition and returns its id. */ - public UUID addNew(String name, CompoundTag bodyTag) { - UUID id = UUID.randomUUID(); - put(id, name, bodyTag); - return id; - } - - public void load(ListTag list) { - definitions.clear(); - for (int i = 0; i < list.size(); i++) { - CompoundTag c = list.getCompound(i); - UUID id = c.getUUID("Id"); - String name = c.getString("Name"); - CompoundTag body = c.getCompound("Body"); - definitions.put(id, new Definition(id, name, body.copy())); - } - } - - public ListTag saveList() { - ListTag list = new ListTag(); - for (Definition d : definitions.values()) { - CompoundTag c = new CompoundTag(); - c.putUUID("Id", d.id()); - c.putString("Name", d.name()); - c.put("Body", d.body().copy()); - list.add(c); - } - return list; - } - - /** - * Copies inner graphs from every {@link FunctionCardNode} on the root graph into this store (creates or - * updates bodies; keeps existing names when possible). - */ - public void syncBodiesFromGraph(WGraph root) { - for (WNode n : root.getNodes()) { - if (n instanceof FunctionCardNode card) { - UUID id = card.getFunctionId(); - Definition existing = definitions.get(id); - String name = existing != null ? existing.name() : "Function"; - put(id, name, card.getInnerGraph().save()); - } - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionEndNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionEndNode.java deleted file mode 100644 index bdf6474..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionEndNode.java +++ /dev/null @@ -1,188 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import dev.propulsionteam.computed.internal.node.api.elements.WButton; -import dev.propulsionteam.computed.internal.node.api.elements.WCheckbox; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import java.util.ArrayList; -import java.util.List; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.StringTag; -import net.minecraft.nbt.Tag; -import net.minecraft.resources.ResourceLocation; - -/** - * Fixed boundary for the function return: named value inputs wired from the subgraph (mirrored as parent - * outputs). Use {@linkplain #emptyCheckbox void} for no returns, or remove all return rows (same effect). - */ -public final class FunctionEndNode extends WNode { - - public static final ResourceLocation TYPE_FN_END = - ResourceLocation.fromNamespaceAndPath("computed", "fn_end"); - - private static final int COLOR_RETURN = 0xFFFFAA66; - - private final WCheckbox emptyCheckbox; - private boolean emptyReturn; - - private final List returnNames = new ArrayList<>(); - private final List returnFields = new ArrayList<>(); - - public FunctionEndNode(int x, int y) { - super(TYPE_FN_END, "End", x, y); - emptyCheckbox = new WCheckbox("No output (void)"); - emptyCheckbox.setOnToggle( - () -> { - emptyReturn = emptyCheckbox.isChecked(); - if (emptyReturn) { - returnNames.clear(); - } else if (returnNames.isEmpty()) { - returnNames.add("Out"); - } - rebuildUiAndPins(); - }); - returnNames.add("Out"); - rebuildUiAndPins(); - setEvaluator(n -> {}); - } - - /** When true, there are no return pins on the parent card and no input pins here. */ - public boolean isEmptyReturn() { - return emptyReturn; - } - - /** - * Refresh return pin labels from text fields and rebuild inputs only when structure changes. Call - * before syncing the parent {@link FunctionCardNode}. - */ - public void syncPinsFromUiFields() { - syncReturnNamesFromFields(); - if (emptyReturn || returnNames.isEmpty()) { - rebuildInputsFromState(); - updateLayout(); - return; - } - List ins = getInputs(); - int expected = returnNames.size(); - if (ins.size() != expected) { - rebuildInputsFromState(); - updateLayout(); - return; - } - for (int j = 0; j < returnNames.size(); j++) { - String nm = returnNames.get(j).trim(); - if (nm.isEmpty()) { - nm = "out" + (j + 1); - } - ins.get(j).setName(nm); - } - updateLayout(); - } - - private void syncReturnNamesFromFields() { - for (int i = 0; i < returnFields.size() && i < returnNames.size(); i++) { - String v = returnFields.get(i).getValue().trim(); - returnNames.set(i, v.isEmpty() ? ("out" + (i + 1)) : v); - } - } - - private void rebuildUiAndPins() { - emptyReturn = emptyCheckbox.isChecked(); - returnFields.clear(); - getElements().clear(); - - addElement(new WLabel("Return")); - addElement(emptyCheckbox); - emptyCheckbox.setChecked(emptyReturn); - - if (!emptyReturn) { - for (int i = 0; i < returnNames.size(); i++) { - final int idx = i; - String nm = returnNames.get(i); - WTextField tf = new WTextField(88); - tf.setValue(nm); - returnFields.add(tf); - addElement(tf); - addElement(new WButton("-", 22, () -> removeReturnAt(idx))); - } - addElement(new WButton("+ return", 72, this::addReturnPressed)); - } - - rebuildInputsFromState(); - updateLayout(); - } - - private void removeReturnAt(int idx) { - if (idx < 0 || idx >= returnNames.size()) { - return; - } - returnNames.remove(idx); - if (returnNames.isEmpty()) { - emptyReturn = true; - emptyCheckbox.setChecked(true); - } - rebuildUiAndPins(); - } - - private void addReturnPressed() { - returnNames.add("out" + (returnNames.size() + 1)); - rebuildUiAndPins(); - } - - private void rebuildInputsFromState() { - getInputs().clear(); - markPinSchemaChanged(); - if (!emptyReturn) { - for (String nm : returnNames) { - addInput(nm.isEmpty() ? "out" : nm, COLOR_RETURN); - } - } - } - - @Override - public boolean isDeletionLocked() { - return true; - } - - @Override - public boolean isDuplicationLocked() { - return true; - } - - @Override - public CompoundTag save() { - syncPinsFromUiFields(); - CompoundTag tag = super.save(); - tag.putBoolean("fnEmptyReturn", emptyReturn); - ListTag list = new ListTag(); - for (String s : returnNames) { - list.add(StringTag.valueOf(s)); - } - tag.put("fnReturns", list); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - emptyReturn = tag.contains("fnEmptyReturn") && tag.getBoolean("fnEmptyReturn"); - returnNames.clear(); - if (tag.contains("fnReturns")) { - ListTag list = tag.getList("fnReturns", Tag.TAG_STRING); - for (int i = 0; i < list.size(); i++) { - returnNames.add(list.getString(i)); - } - } - if (!emptyReturn && returnNames.isEmpty()) { - returnNames.add("Out"); - } - emptyCheckbox.setChecked(emptyReturn); - rebuildUiAndPins(); - } - - public static void register() { - NodeRegistry.register(TYPE_FN_END, FunctionEndNode::new); - NodeMenuRegistry.hideFromAddMenu(TYPE_FN_END); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionStartNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionStartNode.java deleted file mode 100644 index 99b69a4..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/FunctionStartNode.java +++ /dev/null @@ -1,179 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import dev.propulsionteam.computed.internal.node.api.elements.WButton; -import dev.propulsionteam.computed.internal.node.api.elements.WCheckbox; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import java.util.ArrayList; -import java.util.List; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.StringTag; -import net.minecraft.nbt.Tag; -import net.minecraft.resources.ResourceLocation; - -/** - * Fixed boundary inside a function body: optional tick outputs plus named argument outputs. Parent card - * inputs mirror these in order. Tick pulses run only while the nested editor’s play control is active. - */ -public final class FunctionStartNode extends WNode { - - public static final ResourceLocation TYPE_FN_START = - ResourceLocation.fromNamespaceAndPath("computed", "fn_start"); - - /** Matches {@link dev.propulsionteam.computed.internal.node.internal.BuiltinNodes} tick node accent + delta tint (blue/cyan). */ - private static final int COLOR_TICK_PULSE = 0xFF5599FF; - private static final int COLOR_DELTA_TIME = 0xFF88CCFF; - - private final WCheckbox tickCheckbox; - private final List argNames = new ArrayList<>(); - /** Parallel to {@link #argNames} while UI is built. */ - private final List argFields = new ArrayList<>(); - - private boolean tickable; - - public FunctionStartNode(int x, int y) { - super(TYPE_FN_START, "Start", x, y); - tickCheckbox = new WCheckbox("Tickable"); - tickCheckbox.setOnToggle( - () -> { - tickable = tickCheckbox.isChecked(); - rebuildUiAndPins(); - }); - argNames.add("a"); - rebuildUiAndPins(); - setEvaluator(n -> {}); - } - - /** - * Refresh argument pin labels from text fields and rebuild outputs only when structure changes. Call - * before syncing the parent card. - */ - public void syncPinsFromUiFields() { - syncArgNamesFromFields(); - List outs = getOutputs(); - int expected = (tickable ? 2 : 0) + argNames.size(); - if (outs.size() != expected) { - rebuildOutputsFromState(); - updateLayout(); - return; - } - int idx = 0; - if (tickable) { - outs.get(0).setName("Tick"); - outs.get(1).setName("Delta time"); - idx = 2; - } - for (int j = 0; j < argNames.size(); j++) { - String nm = argNames.get(j).trim(); - if (nm.isEmpty()) { - nm = "arg" + (j + 1); - } - outs.get(idx + j).setName(nm); - } - updateLayout(); - } - - private void syncArgNamesFromFields() { - for (int i = 0; i < argFields.size() && i < argNames.size(); i++) { - String v = argFields.get(i).getValue().trim(); - argNames.set(i, v.isEmpty() ? ("arg" + (i + 1)) : v); - } - } - - private void rebuildUiAndPins() { - tickable = tickCheckbox.isChecked(); - argFields.clear(); - getElements().clear(); - - addElement(new WLabel("Start")); - addElement(tickCheckbox); - tickCheckbox.setChecked(tickable); - - for (int i = 0; i < argNames.size(); i++) { - final int idx = i; - String nm = argNames.get(i); - WTextField tf = new WTextField(88); - tf.setValue(nm); - argFields.add(tf); - addElement(tf); - addElement(new WButton("-", 22, () -> removeArgAt(idx))); - } - addElement(new WButton("+ arg", 72, this::addArgPressed)); - - rebuildOutputsFromState(); - updateLayout(); - } - - private void removeArgAt(int idx) { - if (idx < 0 || idx >= argNames.size()) { - return; - } - argNames.remove(idx); - rebuildUiAndPins(); - } - - private void addArgPressed() { - argNames.add("arg" + (argNames.size() + 1)); - rebuildUiAndPins(); - } - - private void rebuildOutputsFromState() { - getOutputs().clear(); - markPinSchemaChanged(); - if (tickable) { - addOutput("Tick", COLOR_TICK_PULSE); - addOutput("Delta time", COLOR_DELTA_TIME); - } - for (String nm : argNames) { - addOutput(nm.isEmpty() ? "arg" : nm, 0xFF88CCFF); - } - } - - public boolean isTickable() { - return tickable; - } - - @Override - public boolean isDeletionLocked() { - return true; - } - - @Override - public boolean isDuplicationLocked() { - return true; - } - - @Override - public CompoundTag save() { - syncPinsFromUiFields(); - CompoundTag tag = super.save(); - tag.putBoolean("fnTickable", tickable); - ListTag list = new ListTag(); - for (String s : argNames) { - list.add(StringTag.valueOf(s)); - } - tag.put("fnArgs", list); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - tickable = tag.contains("fnTickable") && tag.getBoolean("fnTickable"); - argNames.clear(); - if (tag.contains("fnArgs")) { - ListTag list = tag.getList("fnArgs", Tag.TAG_STRING); - for (int i = 0; i < list.size(); i++) { - argNames.add(list.getString(i)); - } - } - tickCheckbox.setChecked(tickable); - rebuildUiAndPins(); - } - - public static void register() { - NodeRegistry.register(TYPE_FN_START, FunctionStartNode::new); - NodeMenuRegistry.hideFromAddMenu(TYPE_FN_START); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/InternalNodeTypeAdapter.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/InternalNodeTypeAdapter.java deleted file mode 100644 index 67c2e35..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/InternalNodeTypeAdapter.java +++ /dev/null @@ -1,276 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import dev.propulsionteam.computed.api.node.ExecutionPolicy; -import dev.propulsionteam.computed.api.node.NodeExecutionContext; -import dev.propulsionteam.computed.api.node.NodeSchema; -import dev.propulsionteam.computed.api.node.NodeType; -import dev.propulsionteam.computed.api.node.PortDefinition; -import dev.propulsionteam.computed.api.node.PortKey; -import dev.propulsionteam.computed.api.node.PortType; -import dev.propulsionteam.computed.content.blocks.ComputedGraphExecution; -import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.regex.Pattern; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; -import net.minecraft.world.level.block.entity.BlockEntity; - -/** - * Transitional executor for Computed-owned nodes that still use the internal editor/runtime model. - * - *

The public descriptor never exposes {@link WNode}: its state is a complete NBT value and its - * evaluator speaks only {@link NodeExecutionContext}. Each evaluation hydrates a fresh internal - * instance from a copy of the immutable prior state, applies typed inputs, executes once, publishes - * typed outputs, and returns a new state snapshot. This makes the public {@link NodeType} executable - * while the individual built-ins are migrated to native executors.

- */ -public final class InternalNodeTypeAdapter { - private static final Pattern VALID_PORT_ID = Pattern.compile("[a-z][a-z0-9_.-]*"); - private static final String TICK_ACCUMULATOR = "ComputedApiTickAccumulator"; - private static final String TICK_LAST_GAME_TICK = "ComputedApiTickLastGameTick"; - - private InternalNodeTypeAdapter() {} - - public static NodeType describe( - NodeRegistry.NodeFactory factory, WNode sample, ResourceLocation category) { - Objects.requireNonNull(factory, "factory"); - Objects.requireNonNull(sample, "sample"); - Objects.requireNonNull(category, "category"); - - ResourceLocation typeId = sample.getTypeId(); - // Saving assigns stable keys to older built-ins whose constructors only supplied labels. - sample.save(); - NodeSchema schema = schema(sample); - boolean stateBoundary = sample.isStateBoundary(); - - return NodeType.builder(typeId) - .title(Component.literal(sample.getTitle())) - .category(category) - .schema(schema) - .stateCodec(CompoundTag.CODEC) - .defaultState(() -> initialState(factory, typeId)) - .stateBoundary(stateBoundary) - .executionPolicy(executionPolicy(sample, stateBoundary)) - .evaluator((priorState, context) -> execute(factory, typeId, priorState, context)) - .build(); - } - - private static CompoundTag execute( - NodeRegistry.NodeFactory factory, - ResourceLocation typeId, - CompoundTag priorState, - NodeExecutionContext context) { - Objects.requireNonNull(priorState, "priorState"); - Objects.requireNonNull(context, "context"); - WNode node = create(factory, typeId); - node.load(priorState.copy()); - copyInputs(context, node); - - double tickAccumulator = 0.0; - if (WGraph.TICK_NODE_TYPE.equals(typeId)) { - tickAccumulator = evaluateTickNode(node, priorState, context); - } else { - evaluateWithContext(node, context); - } - copyOutputs(node, context); - - CompoundTag nextState = node.save(); - if (WGraph.TICK_NODE_TYPE.equals(typeId)) { - nextState.putDouble(TICK_ACCUMULATOR, tickAccumulator); - nextState.putLong(TICK_LAST_GAME_TICK, context.gameTick()); - } - return nextState; - } - - private static void evaluateWithContext(WNode node, NodeExecutionContext context) { - Runnable evaluation = () -> WGraph.evaluateIsolated(node, context.graphStep(), true); - - // Client previews have no server host by construction and never bind one below. Keeping this - // path free of server block-entity linkage also lets descriptor execution be unit tested on - // the API-only classpath. - if (context.isPreview()) { - evaluation.run(); - return; - } - - // Internal world nodes predate NodeExecutionContext and resolve their computer through this - // host scope. Explicit side-effect suppression must not inherit a live server host. - if (!context.sideEffectsAllowed()) { - ComputedGraphExecution.withoutHost(evaluation); - return; - } - - ComputerBlockEntity host = ComputedGraphExecution.hostOrNull(); - if (host == null) { - host = hostFrom(context).orElse(null); - } - if (host == null) { - evaluation.run(); - } else { - ComputedGraphExecution.withHost(host, evaluation); - } - } - - private static Optional hostFrom(NodeExecutionContext context) { - if (context.level().isEmpty() || context.origin().isEmpty()) { - return Optional.empty(); - } - BlockEntity blockEntity = context.level().get().getBlockEntity(context.origin().get()); - return blockEntity instanceof ComputerBlockEntity computer - ? Optional.of(computer) - : Optional.empty(); - } - - private static double evaluateTickNode( - WNode node, CompoundTag priorState, NodeExecutionContext context) { - if (node.getOutputs().size() < 2) { - return 0.0; - } - double accumulator = priorState.contains(TICK_ACCUMULATOR) - ? Math.max(0.0, priorState.getDouble(TICK_ACCUMULATOR)) - : 0.0; - long elapsedTicks = priorState.contains(TICK_LAST_GAME_TICK) - ? Math.max(0L, context.gameTick() - priorState.getLong(TICK_LAST_GAME_TICK)) - : 1L; - accumulator += elapsedTicks / (double) WGraph.MAX_TICK_RATE; - double rate = readTickRate(node); - boolean pulse = false; - if (rate > 1.0e-9 && accumulator >= 1.0 / rate) { - pulse = true; - node.getOutputs().get(1).setValue(accumulator); - accumulator = 0.0; - } else { - node.getOutputs().get(1).setValue(accumulator); - } - node.getOutputs().get(0).setValue(pulse ? 1.0 : 0.0); - return accumulator; - } - - private static double readTickRate(WNode node) { - for (WElement element : node.getElements()) { - if (element instanceof dev.propulsionteam.computed.internal.node.api.elements.WSlider slider) { - return Mth.clamp(slider.getValue(), 0.0, WGraph.MAX_TICK_RATE); - } - } - return WGraph.MAX_TICK_RATE; - } - - private static void copyInputs(NodeExecutionContext context, WNode node) { - List pins = node.getInputs(); - for (int i = 0; i < pins.size(); i++) { - WPin pin = pins.get(i); - String id = stablePortId(pins, i, "input"); - switch (pin.getDataType()) { - case NUMBER -> { - PortKey key = PortKey.of(id, PortType.NUMBER); - pin.setValue(context.input(key)); - pin.setConnected(context.isInputConnected(key)); - } - case STRING -> { - PortKey key = PortKey.of(id, PortType.STRING); - pin.setStringValue(context.input(key)); - pin.setConnected(context.isInputConnected(key)); - } - case WIDGET -> { - PortKey key = PortKey.of(id, PortType.WIDGET); - pin.setWidgetValue(context.input(key)); - pin.setConnected(context.isInputConnected(key)); - } - } - } - } - - private static void copyOutputs(WNode node, NodeExecutionContext context) { - List pins = node.getOutputs(); - for (int i = 0; i < pins.size(); i++) { - WPin pin = pins.get(i); - String id = stablePortId(pins, i, "output"); - switch (pin.getDataType()) { - case NUMBER -> context.output(PortKey.of(id, PortType.NUMBER), pin.getValue()); - case STRING -> context.output(PortKey.of(id, PortType.STRING), pin.getStringValue()); - case WIDGET -> context.output(PortKey.of(id, PortType.WIDGET), pin.getWidgetValue()); - } - } - } - - private static NodeSchema schema(WNode node) { - NodeSchema.Builder schema = NodeSchema.builder(); - addPorts(schema, node.getInputs(), true); - addPorts(schema, node.getOutputs(), false); - return schema.build(); - } - - private static void addPorts(NodeSchema.Builder schema, List pins, boolean input) { - for (int i = 0; i < pins.size(); i++) { - WPin pin = pins.get(i); - String id = stablePortId(pins, i, input ? "input" : "output"); - PortKey key = switch (pin.getDataType()) { - case NUMBER -> PortKey.of(id, PortType.NUMBER); - case STRING -> PortKey.of(id, PortType.STRING); - case WIDGET -> PortKey.of(id, PortType.WIDGET); - }; - schema.port(input - ? PortDefinition.input(key, Component.literal(pin.getName())) - : PortDefinition.output(key, Component.literal(pin.getName()))); - } - } - - private static String stablePortId(List pins, int index, String direction) { - WPin pin = pins.get(index); - String explicit = pin.getStableKey(); - if (explicit != null && VALID_PORT_ID.matcher(explicit).matches()) { - return explicit; - } - - String label = slug(pin.getName()); - String base = direction + "." + label; - Map occurrences = new HashMap<>(); - for (int i = 0; i <= index; i++) { - String candidate = direction + "." + slug(pins.get(i).getName()); - occurrences.merge(candidate, 1, Integer::sum); - } - int duplicate = occurrences.getOrDefault(base, 1); - return duplicate == 1 ? base : base + "." + duplicate; - } - - private static String slug(String label) { - String slug = label == null ? "port" : label.toLowerCase(Locale.ROOT) - .replaceAll("[^a-z0-9_.-]+", "_") - .replaceAll("^[^a-z]+", "") - .replaceAll("_+$", ""); - return slug.isEmpty() ? "port" : slug; - } - - private static ExecutionPolicy executionPolicy(WNode node, boolean stateBoundary) { - ExecutionPolicy declared = node.executionPolicy(); - if (declared != ExecutionPolicy.INPUT_DRIVEN) { - return declared; - } - if (stateBoundary) { - return ExecutionPolicy.EVERY_GRAPH_STEP; - } - return node.getInputs().isEmpty() - ? ExecutionPolicy.EVERY_GAME_TICK - : ExecutionPolicy.INPUT_DRIVEN; - } - - private static CompoundTag initialState(NodeRegistry.NodeFactory factory, ResourceLocation typeId) { - return create(factory, typeId).save(); - } - - private static WNode create(NodeRegistry.NodeFactory factory, ResourceLocation typeId) { - WNode node = Objects.requireNonNull(factory.create(0, 0), "Factory returned null for " + typeId); - if (!typeId.equals(node.getTypeId())) { - throw new IllegalStateException( - "Factory for " + typeId + " created node type " + node.getTypeId()); - } - return node; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/MuxNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/MuxNode.java deleted file mode 100644 index afbecd4..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/MuxNode.java +++ /dev/null @@ -1,93 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import dev.propulsionteam.computed.internal.node.api.elements.WButton; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -/** - * Multiplexer with a configurable input count (2-16). {@code Select} input is floored and clamped to an - * index, routing the corresponding {@code In i} pin's value to {@code Out}. Use the +/- buttons to grow - * or shrink the data-input set. - */ -public final class MuxNode extends WNode { - - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath("computed", "mux"); - - private static final int MIN_INPUTS = 2; - private static final int MAX_INPUTS = 16; - - private int inputCount = 2; - - public MuxNode(int x, int y) { - super(TYPE_ID, "MUX", x, y); - rebuildUiAndPins(); - setEvaluator(n -> { - int count = n.getInputs().size() - 1; - if (count <= 0) { - n.getOutputs().get(0).setValue(0.0); - return; - } - double sel = n.getInputs().get(0).getValue(); - int idx = Mth.clamp((int) Math.floor(sel), 0, count - 1); - n.getOutputs().get(0).setValue(n.getInputs().get(1 + idx).getValue()); - }); - } - - private void rebuildUiAndPins() { - getInputs().clear(); - getOutputs().clear(); - markPinSchemaChanged(); - getElements().clear(); - - addInput("Select", 0xFF00FF88); - for (int i = 0; i < inputCount; i++) { - addInput("In " + i, 0xFF88CCFF); - } - addOutput("Out", 0xFFFF5555); - - addElement(new WLabel("Select picks In 0.." + (inputCount - 1))); - addElement(new WButton("+ in", 40, () -> { - if (inputCount < MAX_INPUTS) { - inputCount++; - rebuildUiAndPins(); - } - })); - addElement(new WButton("- in", 40, () -> { - if (inputCount > MIN_INPUTS) { - inputCount--; - rebuildUiAndPins(); - } - })); - updateLayout(); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putInt("inputCount", inputCount); - return tag; - } - - @Override - public void load(CompoundTag tag) { - if (tag.contains("inputCount")) { - int requested = tag.getInt("inputCount"); - inputCount = Mth.clamp(requested, MIN_INPUTS, MAX_INPUTS); - rebuildUiAndPins(); - } - super.load(tag); - } - - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("MUX"); - - public static void register() { - NodeRegistry.register(TYPE_ID, MuxNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/NodeMenuRegistry.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/NodeMenuRegistry.java deleted file mode 100644 index fa0ff36..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/NodeMenuRegistry.java +++ /dev/null @@ -1,172 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; - -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -/** - * Hierarchical "add node" menu: categories (submenus) and node entries. Mods register categories and - * place node types into any category. Node types registered in {@link NodeRegistry} but not added here - * appear under {@link #UNCATEGORIZED}. - */ -public final class NodeMenuRegistry { - /** Parent id for top-level categories. */ - public static final ResourceLocation ROOT = ResourceLocation.fromNamespaceAndPath("computed", "menu_root"); - /** Built-in category for node types with no explicit menu entry. */ - public static final ResourceLocation UNCATEGORIZED = - ResourceLocation.fromNamespaceAndPath("computed", "menu_uncategorized"); - - public record Category(ResourceLocation id, Component title, ResourceLocation parentId) {} - - public record MenuEntry(ResourceLocation categoryId, ResourceLocation nodeType, Component label) {} - - private static final Map CATEGORIES = new LinkedHashMap<>(); - private static final List ENTRIES = new ArrayList<>(); - private static final java.util.Set EXPLICIT_NODE_TYPES = new java.util.HashSet<>(); - /** Types never listed under uncategorized or search (editor-only / hidden nodes). */ - private static final Set HIDDEN_FROM_ADD_MENU = new HashSet<>(); - - private NodeMenuRegistry() {} - - /** - * Register a submenu folder. {@code parentId} is usually {@link #ROOT} or another category id. - */ - public static void registerCategory(ResourceLocation id, Component title, ResourceLocation parentId) { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(title, "title"); - Objects.requireNonNull(parentId, "parentId"); - CATEGORIES.put(id, new Category(id, title, parentId)); - } - - /** - * Add a node type to a category. The node type must also be registered with {@link NodeRegistry}. - */ - public static void addNodeEntry(ResourceLocation categoryId, ResourceLocation nodeType, Component label) { - Objects.requireNonNull(categoryId, "categoryId"); - Objects.requireNonNull(nodeType, "nodeType"); - Objects.requireNonNull(label, "label"); - ENTRIES.add(new MenuEntry(categoryId, nodeType, label)); - EXPLICIT_NODE_TYPES.add(nodeType); - } - - /** Hidden nodes stay off the add-node menu and search (still in {@link NodeRegistry}). */ - public static void hideFromAddMenu(ResourceLocation nodeType) { - Objects.requireNonNull(nodeType, "nodeType"); - HIDDEN_FROM_ADD_MENU.add(nodeType); - } - - public static void removeNodeEntriesForTypes(Set nodeTypes) { - if (nodeTypes == null || nodeTypes.isEmpty()) { - return; - } - ENTRIES.removeIf(e -> nodeTypes.contains(e.nodeType())); - EXPLICIT_NODE_TYPES.removeAll(nodeTypes); - HIDDEN_FROM_ADD_MENU.removeAll(nodeTypes); - } - - public static void removeCategories(Set categoryIds) { - if (categoryIds == null || categoryIds.isEmpty()) { - return; - } - ENTRIES.removeIf(e -> categoryIds.contains(e.categoryId())); - CATEGORIES.keySet().removeIf(categoryIds::contains); - } - - public static Category getCategory(ResourceLocation id) { - return CATEGORIES.get(id); - } - - public static List getCategories() { - return List.copyOf(CATEGORIES.values()); - } - - public static List getExplicitEntries() { - return List.copyOf(ENTRIES); - } - - public static List getChildCategories(ResourceLocation parentId) { - return CATEGORIES.values().stream().filter(c -> c.parentId().equals(parentId)).toList(); - } - - /** Explicit entries only (not uncategorized). */ - public static List getExplicitEntriesIn(ResourceLocation categoryId) { - return ENTRIES.stream().filter(e -> e.categoryId().equals(categoryId)).toList(); - } - - public static List getEntriesIn(ResourceLocation categoryId) { - if (categoryId.equals(UNCATEGORIZED)) { - return List.copyOf(computeUncategorized()); - } - return getExplicitEntriesIn(categoryId); - } - - private static List computeUncategorized() { - List list = new ArrayList<>(); - for (ResourceLocation type : NodeRegistry.getRegisteredTypes()) { - if (!EXPLICIT_NODE_TYPES.contains(type) && !HIDDEN_FROM_ADD_MENU.contains(type)) { - list.add(new MenuEntry(UNCATEGORIZED, type, defaultLabelFor(type))); - } - } - return list; - } - - private static Component defaultLabelFor(ResourceLocation type) { - return Component.literal(type.getNamespace() + ":" + type.getPath()); - } - - /** Flat list for search: every placeable node with its label. */ - public static List allSearchableEntries() { - List all = new ArrayList<>(ENTRIES); - all.addAll(computeUncategorized()); - return all; - } - - public static List filterEntries(String query) { - String q = query.toLowerCase(Locale.ROOT).trim(); - if (q.isEmpty()) { - return List.of(); - } - return allSearchableEntries().stream() - .filter(e -> entryMatches(e, q)) - .toList(); - } - - private static boolean entryMatches(MenuEntry e, String q) { - if (e.label().getString().toLowerCase(Locale.ROOT).contains(q)) { - return true; - } - ResourceLocation t = e.nodeType(); - if (t.getPath().toLowerCase(Locale.ROOT).contains(q) - || t.getNamespace().toLowerCase(Locale.ROOT).contains(q)) { - return true; - } - return categoryTitlePath(e.categoryId()).toLowerCase(Locale.ROOT).contains(q); - } - - private static String categoryTitlePath(ResourceLocation categoryId) { - if (categoryId.equals(ROOT)) { - return ""; - } - List parts = new ArrayList<>(); - ResourceLocation id = categoryId; - int guard = 0; - while (id != null && !id.equals(ROOT) && guard++ < 64) { - Category c = CATEGORIES.get(id); - if (c == null) { - break; - } - parts.add(c.title().getString()); - id = c.parentId(); - } - return parts.reversed().stream().collect(Collectors.joining(" / ")); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/NodeRegistry.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/NodeRegistry.java deleted file mode 100644 index 6d47bc3..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/NodeRegistry.java +++ /dev/null @@ -1,64 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import net.minecraft.resources.ResourceLocation; -import org.slf4j.Logger; -import com.mojang.logging.LogUtils; -import java.util.LinkedHashMap; -import java.util.Map; - -public class NodeRegistry { - private static final String LEGACY_NAMESPACE = "websnodelib"; - private static final String COMPUTED_NAMESPACE = "computed"; - private static final Logger LOGGER = LogUtils.getLogger(); - private static final Map REGISTRY = new LinkedHashMap<>(); - - public static void register(ResourceLocation id, NodeFactory factory) { - ResourceLocation canonicalId = canonicalize(id); - if (REGISTRY.putIfAbsent(canonicalId, factory) != null) { - throw new IllegalStateException("Duplicate node type registration: " + canonicalId); - } - } - - public static boolean isRegistered(ResourceLocation id) { - return REGISTRY.containsKey(canonicalize(id)); - } - - public static void unregister(ResourceLocation id) { - REGISTRY.remove(canonicalize(id)); - } - - public static WNode createNode(ResourceLocation id, int x, int y) { - ResourceLocation canonicalId = canonicalize(id); - NodeFactory factory = REGISTRY.get(canonicalId); - if (factory != null) { - try { - return factory.create(x, y); - } catch (Throwable t) { - LOGGER.error("Failed to create node type {} at ({}, {})", canonicalId, x, y, t); - return null; - } - } - return null; - } - - /** Maps node IDs written by the vendored pre-rewrite engine to their Computed-owned IDs. */ - public static ResourceLocation canonicalize(ResourceLocation id) { - if (id != null && LEGACY_NAMESPACE.equals(id.getNamespace())) { - return ResourceLocation.fromNamespaceAndPath(COMPUTED_NAMESPACE, id.getPath()); - } - return id; - } - - public static java.util.Set getRegisteredTypes() { - return REGISTRY.keySet(); - } - - public static Map getRegistry() { - return REGISTRY; - } - - @FunctionalInterface - public interface NodeFactory { - WNode create(int x, int y); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/PassOnNthRisingEdgeNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/PassOnNthRisingEdgeNode.java deleted file mode 100644 index 417d3bd..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/PassOnNthRisingEdgeNode.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -/** - * On each rising edge of {@code In} (> 0.5), counts toward {@code N}. On the Nth rise, {@code Out} is the - * current {@code In} value for that one evaluation, then the count resets. Otherwise {@code Out} is 0. - * {@code Reset} rising edge clears the count with no output. - */ -public final class PassOnNthRisingEdgeNode extends WNode { - - /** Kept for graph save compatibility. */ - public static final ResourceLocation TYPE_ID = - ResourceLocation.fromNamespaceAndPath("computed", "pass_every_n"); - - private int edgesSeen; - private boolean prevInHigh; - private boolean prevResetHigh; - - private final WTextField nField; - - public PassOnNthRisingEdgeNode(int x, int y) { - super(TYPE_ID, "Pass on Nth rise", x, y); - addInput("In", 0xFF00FF88); - addInput("Reset", 0xFFFF6666); - addOutput("Out", 0xFFFFBB00); - - addElement(new WLabel("Out = In once on Nth rise (>0.5)")); - addElement(new WLabel("N (integer ≥1)")); - nField = new WTextField(72); - nField.setValue("3"); - addElement(nField); - - setEvaluator(this::evaluateSelf); - } - - private void evaluateSelf(WNode n) { - int nTarget = parsePositiveInt(nField, 3, 1_000_000); - - n.getOutputs().get(0).setValue(0.0); - - boolean inHigh = n.getInputs().get(0).getValue() > 0.5; - boolean resetHigh = n.getInputs().size() > 1 && n.getInputs().get(1).getValue() > 0.5; - boolean resetRise = resetHigh && !prevResetHigh; - boolean inRise = inHigh && !prevInHigh; - prevResetHigh = resetHigh; - prevInHigh = inHigh; - - if (resetRise) { - edgesSeen = 0; - return; - } - if (inRise) { - edgesSeen++; - if (edgesSeen >= nTarget) { - n.getOutputs().get(0).setValue(n.getInputs().get(0).getValue()); - edgesSeen = 0; - } - } - } - - private static int parsePositiveInt(WTextField field, int fallback, int max) { - try { - String s = field.getValue().trim(); - if (s.isEmpty()) { - return fallback; - } - int v = Integer.parseInt(s.replace(",", "")); - return Mth.clamp(v, 1, max); - } catch (NumberFormatException e) { - return fallback; - } - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putInt("EdgesSeen", edgesSeen); - tag.putBoolean("PrevInHigh", prevInHigh); - tag.putBoolean("PrevResetHigh", prevResetHigh); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - if (tag.contains("EdgesSeen")) { - edgesSeen = tag.getInt("EdgesSeen"); - } - prevInHigh = tag.getBoolean("PrevInHigh"); - prevResetHigh = tag.getBoolean("PrevResetHigh"); - } - - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Pass on Nth rise"); - - public static void register() { - NodeRegistry.register(TYPE_ID, PassOnNthRisingEdgeNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/UiKeyTextures.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/UiKeyTextures.java deleted file mode 100644 index 4d7635c..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/UiKeyTextures.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import net.minecraft.resources.ResourceLocation; - -/** Key-cap textures under {@code assets/computed/textures/ui/icons/keys/.png}. */ -public final class UiKeyTextures { - private static final String NS = "computed"; - private static final String PREFIX = "textures/ui/icons/keys/"; - - private UiKeyTextures() {} - - public static ResourceLocation key(String basename) { - return ResourceLocation.fromNamespaceAndPath(NS, PREFIX + basename + ".png"); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/WElement.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/WElement.java deleted file mode 100644 index 0c8c816..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/WElement.java +++ /dev/null @@ -1,89 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import net.minecraft.client.gui.GuiGraphics; - -/** - * The base class for all interactive and visual components inside a node (WNode). - * Elements are arranged vertically within the node body. - * Subclasses implement specific UI components like buttons, sliders, or labels. - */ -public abstract class WElement { - protected int width; - protected int height; - protected int padding = 2; - protected int margin = 2; - /** Set by {@link WNode#addElement(WElement)} so resizing elements can flip the parent's layout-dirty flag. */ - WNode parent; - - /** Call when this element's measured width/height changes so the parent re-runs layout next frame. */ - protected final void markLayoutDirty() { - if (parent != null) parent.markLayoutDirty(); - } - - /** - * Renders the element at the specified logical coordinates. - * @param graphics The GuiGraphics context. - * @param x Top-left X coordinate of the element's bounding box. - * @param y Top-left Y coordinate of the element's bounding box. - * @param mouseX Current transformed mouse X. - * @param mouseY Current transformed mouse Y. - * @param partialTick Animation frame fraction. - */ - public abstract void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick); - - /** - * @return Total width including padding. - */ - public int getWidth() { - return width + padding * 2; - } - - /** - * @return Total height including padding and margin. - */ - public int getHeight() { - return height + padding * 2 + margin * 2; - } - - public void setWidth(int width) { this.width = width; } - public void setHeight(int height) { this.height = height; } - - /** - * Serializes element state (e.g., slider value, text field content). - */ - public net.minecraft.nbt.CompoundTag save() { return new net.minecraft.nbt.CompoundTag(); } - - /** - * Loads element state from NBT. - */ - public void load(net.minecraft.nbt.CompoundTag tag) {} - - /** - * Handles mouse click events local to the element. - * @return True if the event was consumed. - */ - public boolean handleMouseClick(double localX, double localY, int button) { return false; } - - /** - * Handles mouse release events. - */ - public boolean handleMouseRelease(double mouseX, double mouseY, int button) { return false; } - - /** - * Handles keyboard key presses. - */ - public boolean handleKeyPress(int keyCode, int scanCode, int modifiers) { return false; } - - /** - * Handles character input. - */ - public boolean handleCharTyped(char codePoint, int modifiers) { return false; } - - /** - * @return True when this element currently has input focus. - */ - public boolean isFocused() { return false; } - - /** Clears transient keyboard or drag focus when the editor changes to a non-interactive LOD. */ - public void clearFocus() {} -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/WGraph.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/WGraph.java index 905c5e2..5463d3d 100644 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/WGraph.java +++ b/src/main/java/dev/propulsionteam/computed/internal/node/api/WGraph.java @@ -1,9 +1,8 @@ package dev.propulsionteam.computed.internal.node.api; -import dev.propulsionteam.computed.internal.node.MissingNode; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; @@ -11,1193 +10,315 @@ import java.util.Map; import java.util.Set; import java.util.UUID; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -/** - * The core data structure for the node system. - * A WGraph manages a collection of nodes and the connections between them. - * It is responsible for logical updates (ticking) and data flow propagation. - */ -public class WGraph { - - public enum DiagnosticSeverity { WARNING, ERROR } - - /** Runtime/editor diagnostic that never removes the source graph data. */ - public record GraphDiagnostic( - DiagnosticSeverity severity, String code, String message, Set nodeIds) {} - - /** Node type id for the graph tick driver (menu "Tick"). */ - public static final ResourceLocation TICK_NODE_TYPE = - ResourceLocation.fromNamespaceAndPath("computed", "tick"); - - /** Maximum updates per second for the tick node's Rate slider (matches default Minecraft TPS). */ - public static final int MAX_TICK_RATE = 20; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +public final class WGraph { private final List nodes = new ArrayList<>(); private final List connections = new ArrayList<>(); - private final List sections = new ArrayList<>(); - /** UUID→node lookup; kept in sync with {@link #nodes} to avoid O(n) stream scans in hot render paths. */ private final Map nodeIndex = new HashMap<>(); - private boolean pinSchemaRefreshPending; + private final Map nodeArchive = new HashMap<>(); private long connectionGeometryRevision; - private final Map> outgoingConnections = new HashMap<>(); - private final Map> incomingConnections = new HashMap<>(); - private final Set forcedDirtySources = new HashSet<>(); - private final Set disabledNodeIds = new HashSet<>(); - private final List diagnostics = new ArrayList<>(); - private final List evaluationOrder = new ArrayList<>(); - private boolean forceFullWorldStep = true; - /** O(1) node lookup by id. Returns null if not present. */ public WNode getNode(UUID id) { return id == null ? null : nodeIndex.get(id); } - /** Grouping rectangle shown in the editor. */ - public static class WSection { - /** Default body fill (ARGB) matching the original editor theme. */ - public static final int DEFAULT_BODY_COLOR_ARGB = 0x221F2A40; - - private UUID id; - private String name; - private int x; - private int y; - private int width; - private int height; - /** Editor-only: section background tint (ARGB). */ - private int bodyColorArgb = DEFAULT_BODY_COLOR_ARGB; - /** - * Draw / hit-test order for nested sections: 0 = root band, larger = more nested (drawn on top, - * receives header clicks first). - */ - private int layer; - - public WSection(String name, int x, int y, int width, int height) { - this.id = UUID.randomUUID(); - this.name = name; - this.x = x; - this.y = y; - this.width = width; - this.height = height; - } - - public UUID getId() { return id; } - public String getName() { return name; } - public void setName(String name) { this.name = name; } - public int getX() { return x; } - public int getY() { return y; } - public int getWidth() { return width; } - public int getHeight() { return height; } - public void setPos(int x, int y) { this.x = x; this.y = y; } - public void setSize(int width, int height) { this.width = width; this.height = height; } - - public int getBodyColorArgb() { - return bodyColorArgb; - } - - public void setBodyColorArgb(int argb) { - this.bodyColorArgb = argb; - } - - public int getLayer() { - return layer; - } - - public void setLayer(int layer) { - this.layer = Math.max(0, layer); - } - - private net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = new net.minecraft.nbt.CompoundTag(); - tag.putString("id", id.toString()); - tag.putString("name", name); - tag.putInt("x", x); - tag.putInt("y", y); - tag.putInt("w", width); - tag.putInt("h", height); - tag.putInt("bodyArgb", bodyColorArgb); - tag.putInt("layer", layer); - return tag; - } - - public net.minecraft.nbt.CompoundTag toNbt() { - return save(); - } - - public static WSection fromNbt(net.minecraft.nbt.CompoundTag tag) { - return load(tag); - } - - private static WSection load(net.minecraft.nbt.CompoundTag tag) { - WSection s = new WSection( - tag.getString("name"), - tag.getInt("x"), - tag.getInt("y"), - Math.max(24, tag.getInt("w")), - Math.max(24, tag.getInt("h"))); - if (tag.contains("id")) { - s.id = UUID.fromString(tag.getString("id")); - } - if (tag.contains("bodyArgb")) { - s.bodyColorArgb = tag.getInt("bodyArgb"); - } - if (tag.contains("layer")) { - s.layer = Math.max(0, tag.getInt("layer")); - } - return s; - } + public List getNodes() { + return nodes; } - /** Seconds accumulated toward the next pulse, per tick-node id. */ - private final Map tickAccumSec = new HashMap<>(); - - /** - * Increments once after each full {@link #stepConnectionsAndEval(boolean)} (root graph world ticks, - * {@link #advanceSimulation(double)} steps, and each {@link #propagateAndEvaluate()} for nested graphs). - * Nodes can use it to emit at most once per logical graph step. - */ - private int simulationStepCounter = 0; + public List getConnections() { + return connections; + } - /** - * While nodes evaluate after wire propagation: whether this pass counts as a tick-node pulse (matches Tick - * output high) or, with no tick driver, is always true for that pass. - */ - private boolean evalTickPulseGate; + public long getConnectionGeometryRevision() { + return connectionGeometryRevision; + } - /** - * Adds a new node to the graph and recalculates the topological structure. - * @param node The node instance to add. - */ public void addNode(WNode node) { + if (node == null || nodeIndex.containsKey(node.getId())) { + return; + } nodes.add(node); nodeIndex.put(node.getId(), node); + nodeArchive.put(node.getId(), node); node.bindOwningGraph(this); - dedupeFunctionBoundaryNodes(); - pruneDanglingConnections(); updateTopology(); } - /** - * Removes a node and all its associated connections from the graph. - * @param node The node to remove. - */ public void removeNode(WNode node) { + if (node == null || !nodeIndex.containsKey(node.getId())) { + return; + } nodes.remove(node); nodeIndex.remove(node.getId()); node.bindOwningGraph(null); - connections.removeIf(c -> c.sourceNode().equals(node.getId()) || c.targetNode().equals(node.getId())); + connections.removeIf(connection -> + connection.sourceNode().equals(node.getId()) || connection.targetNode().equals(node.getId())); updateTopology(); } - /** - * Serializes the entire graph state into a NBT CompoundTag. - * @return A tag containing all nodes, their internal data, and connections. - */ - public net.minecraft.nbt.CompoundTag save() { - refreshStableConnectionPins(); - net.minecraft.nbt.CompoundTag tag = new net.minecraft.nbt.CompoundTag(); - - net.minecraft.nbt.ListTag nodesTag = new net.minecraft.nbt.ListTag(); - for (WNode node : nodes) nodesTag.add(node.save()); - tag.put("nodes", nodesTag); - - net.minecraft.nbt.ListTag connsTag = new net.minecraft.nbt.ListTag(); - for (WConnection conn : connections) { - net.minecraft.nbt.CompoundTag c = new net.minecraft.nbt.CompoundTag(); - c.putString("src", conn.sourceNode().toString()); - c.putInt("srcP", conn.sourcePin()); - c.putString("tgt", conn.targetNode().toString()); - c.putInt("tgtP", conn.targetPin()); - WNode source = nodeIndex.get(conn.sourceNode()); - WNode target = nodeIndex.get(conn.targetNode()); - String sourcePort = conn.sourcePortKey(); - if (sourcePort == null && source != null && conn.sourcePin() >= 0 && conn.sourcePin() < source.getOutputs().size()) { - sourcePort = stablePortId(source.getOutputs(), conn.sourcePin(), "output"); - } - if (sourcePort != null) c.putString("sourcePort", sourcePort); - String targetPort = conn.targetPortKey(); - if (targetPort == null && target != null && conn.targetPin() >= 0 && conn.targetPin() < target.getInputs().size()) { - targetPort = stablePortId(target.getInputs(), conn.targetPin(), "input"); - } - if (targetPort != null) c.putString("targetPort", targetPort); - if (conn.waypointXs().length > 0) { - net.minecraft.nbt.ListTag wps = new net.minecraft.nbt.ListTag(); - for (int j = 0; j < conn.waypointXs().length; j++) { - net.minecraft.nbt.CompoundTag w = new net.minecraft.nbt.CompoundTag(); - w.putInt("x", conn.waypointXs()[j]); - w.putInt("y", conn.waypointYs()[j]); - wps.add(w); - } - c.put("wps", wps); - } - connsTag.add(c); - } - tag.put("conns", connsTag); - - net.minecraft.nbt.ListTag sectionsTag = new net.minecraft.nbt.ListTag(); - for (WSection s : sections) { - sectionsTag.add(s.save()); - } - tag.put("sections", sectionsTag); - - return tag; - } - - private static String stablePortId(List pins, int index, String direction) { - return WNode.stablePortId(pins, index, direction); - } - - /** - * Reconstructs the graph state from a NBT CompoundTag. - * @param tag The tag containing serialized graph data. - */ - public void load(net.minecraft.nbt.CompoundTag tag) { - nodes.clear(); - nodeIndex.clear(); - pinSchemaRefreshPending = false; - connections.clear(); - sections.clear(); - - net.minecraft.nbt.ListTag nodesTag = tag.getList("nodes", 10); - for (int i = 0; i < nodesTag.size(); i++) { - net.minecraft.nbt.CompoundTag nTag = nodesTag.getCompound(i); - net.minecraft.resources.ResourceLocation type = net.minecraft.resources.ResourceLocation.parse(nTag.getString("typeId")); - WNode node = NodeRegistry.createNode(type, nTag.getInt("x"), nTag.getInt("y")); - if (node == null) node = MissingNode.fromLegacyTag(type, nTag); - node.load(nTag); - nodes.add(node); - nodeIndex.put(node.getId(), node); - node.bindOwningGraph(this); - } - - net.minecraft.nbt.ListTag connsTag = tag.getList("conns", 10); - for (int i = 0; i < connsTag.size(); i++) { - net.minecraft.nbt.CompoundTag c = connsTag.getCompound(i); - java.util.UUID src = java.util.UUID.fromString(c.getString("src")); - int sp = c.getInt("srcP"); - java.util.UUID tgt = java.util.UUID.fromString(c.getString("tgt")); - int tp = c.getInt("tgtP"); - String sourcePort = c.contains("sourcePort") ? c.getString("sourcePort") : null; - String targetPort = c.contains("targetPort") ? c.getString("targetPort") : null; - if (c.contains("sourcePort")) { - sp = stablePortIndex(nodeIndex.get(src), true, c.getString("sourcePort"), sp); - } - if (c.contains("targetPort")) { - tp = stablePortIndex(nodeIndex.get(tgt), false, c.getString("targetPort"), tp); - } - if (c.contains("wps")) { - net.minecraft.nbt.ListTag wps = c.getList("wps", 10); - int[] wx = new int[wps.size()]; - int[] wy = new int[wps.size()]; - for (int j = 0; j < wps.size(); j++) { - net.minecraft.nbt.CompoundTag w = wps.getCompound(j); - wx[j] = w.getInt("x"); - wy[j] = w.getInt("y"); - } - connections.add(new WConnection(src, sp, tgt, tp, wx, wy, sourcePort, targetPort)); - } else { - connections.add(new WConnection(src, sp, tgt, tp, null, null, sourcePort, targetPort)); - } - } - net.minecraft.nbt.ListTag sectionsTag = tag.getList("sections", 10); - for (int i = 0; i < sectionsTag.size(); i++) { - sections.add(WSection.load(sectionsTag.getCompound(i))); - } - dedupeFunctionBoundaryNodes(); - pruneDanglingConnections(); - tickAccumSec.clear(); - simulationStepCounter = 0; - updateTopology(); - } - - private static int stablePortIndex(WNode node, boolean output, String key, int fallback) { - if (node == null || key == null || key.isBlank()) return fallback; - List pins = output ? node.getOutputs() : node.getInputs(); - String direction = output ? "output" : "input"; - for (int i = 0; i < pins.size(); i++) { - if (key.equals(stablePortId(pins, i, direction))) return i; - } - return fallback; - } - - private static int stablePortIndex(WNode node, boolean output, String key) { - return stablePortIndex(node, output, key, -1); - } - - /** Remaps positional caches only after a node reports a schema generation change. */ - private void refreshStableConnectionPins() { - if (!pinSchemaRefreshPending) return; - pinSchemaRefreshPending = false; - for (WConnection connection : connections) { - int sourcePin = connection.sourcePortKey() == null - ? connection.sourcePin() - : stablePortIndex(nodeIndex.get(connection.sourceNode()), true, connection.sourcePortKey()); - int targetPin = connection.targetPortKey() == null - ? connection.targetPin() - : stablePortIndex(nodeIndex.get(connection.targetNode()), false, connection.targetPortKey()); - connection.resolvePins(sourcePin, targetPin); - } - rebuildConnectionIndexes(); - analyzeCombinationalCycles(); - rebuildEvaluationOrder(); - forceFullWorldStep = true; - connectionGeometryRevision++; - } - - void onNodePinSchemaChanged(WNode node) { - if (node != null && nodeIndex.get(node.getId()) == node) pinSchemaRefreshPending = true; - } - - public long getConnectionGeometryRevision() { - refreshStableConnectionPins(); - return connectionGeometryRevision; - } - - /** - * Function inner graphs must have at most one {@link FunctionStartNode} and one {@link FunctionEndNode}. - * Keeps the first of each in list order and removes extras (and connections touching them). - */ - private void dedupeFunctionBoundaryNodes() { - boolean haveStart = false; - boolean haveEnd = false; - List extras = new ArrayList<>(); - for (WNode n : nodes) { - if (n instanceof FunctionStartNode) { - if (haveStart) { - extras.add(n); - } else { - haveStart = true; - } - } else if (n instanceof FunctionEndNode) { - if (haveEnd) { - extras.add(n); - } else { - haveEnd = true; - } - } - } - if (extras.isEmpty()) { - return; - } - Set extraIds = new HashSet<>(); - for (WNode n : extras) { - extraIds.add(n.getId()); - nodeIndex.remove(n.getId()); - n.bindOwningGraph(null); - } - connections.removeIf( - c -> extraIds.contains(c.sourceNode()) || extraIds.contains(c.targetNode())); - nodes.removeAll(extras); - } - - /** Removes connections whose endpoints are not present (e.g. skipped nodes while loading). */ - private void pruneDanglingConnections() { - if (connections.isEmpty()) { - return; - } - Set ids = new HashSet<>(); - for (WNode n : nodes) { - ids.add(n.getId()); - } - connections.removeIf(c -> !ids.contains(c.sourceNode()) || !ids.contains(c.targetNode())); - } - - /** Returns true when the candidate closes a same-step dependency cycle. */ - public boolean wouldIntroduceCombinationalCycle(WConnection candidate) { - WNode source = nodeIndex.get(candidate.sourceNode()); - WNode target = nodeIndex.get(candidate.targetNode()); - if (source == null || target == null || source.isStateBoundary()) { - return false; - } - if (source.getId().equals(target.getId())) { - return true; - } - ArrayDeque pending = new ArrayDeque<>(); - Set visited = new HashSet<>(); - pending.add(target.getId()); - while (!pending.isEmpty()) { - UUID current = pending.removeFirst(); - if (!visited.add(current)) { - continue; - } - if (current.equals(source.getId())) { - return true; - } - WNode currentNode = nodeIndex.get(current); - if (currentNode != null && currentNode.isStateBoundary()) { - continue; - } - for (WConnection connection : connections) { - if (!connection.sourceNode().equals(current)) { - continue; - } - WNode next = nodeIndex.get(connection.targetNode()); - if (next != null) { - pending.addLast(next.getId()); - } - } - } - return false; - } - - /** - * Establishes a connection between an output pin of a source node and an input pin of a target node. - * @param sourceNode UUID of the source node. - * @param sourcePin Index of the output pin. - * @param targetNode UUID of the target node. - * @param targetPin Index of the input pin. - */ public boolean connect(UUID sourceNode, int sourcePin, UUID targetNode, int targetPin) { return connect(WConnection.withoutWaypoints(sourceNode, sourcePin, targetNode, targetPin)); } - /** Like {@link #connect(UUID, int, UUID, int)} but preserves editor spline waypoints (paste, tools). */ public boolean connect(WConnection connection) { - connection = withStablePortKeys(connection); - if (wouldIntroduceCombinationalCycle(connection)) { - diagnostics.add(new GraphDiagnostic( - DiagnosticSeverity.ERROR, - "computed.cycle.rejected", - "Connection rejected: combinational cycles require a state or delay node", - Set.of(connection.sourceNode(), connection.targetNode()))); - return false; - } - connections.add(connection); - updateTopology(); - return true; - } - - private WConnection withStablePortKeys(WConnection connection) { - String sourceKey = connection.sourcePortKey(); - String targetKey = connection.targetPortKey(); - WNode source = nodeIndex.get(connection.sourceNode()); - WNode target = nodeIndex.get(connection.targetNode()); - if (sourceKey == null && source != null && connection.sourcePin() >= 0 - && connection.sourcePin() < source.getOutputs().size()) { - sourceKey = stablePortId(source.getOutputs(), connection.sourcePin(), "output"); - } - if (targetKey == null && target != null && connection.targetPin() >= 0 - && connection.targetPin() < target.getInputs().size()) { - targetKey = stablePortId(target.getInputs(), connection.targetPin(), "input"); - } - return connection.withStablePorts(sourceKey, targetKey); - } - - private boolean isConnectionUsable(WConnection connection) { - WNode source = nodeIndex.get(connection.sourceNode()); - WNode target = nodeIndex.get(connection.targetNode()); + WNode source = getNode(connection.sourceNode()); + WNode target = getNode(connection.targetNode()); if (source == null || target == null + || source == target || connection.sourcePin() < 0 || connection.sourcePin() >= source.getOutputs().size() || connection.targetPin() < 0 - || connection.targetPin() >= target.getInputs().size()) { + || connection.targetPin() >= target.getInputs().size() + || source.getOutputs().get(connection.sourcePin()).getDataType() + != target.getInputs().get(connection.targetPin()).getDataType()) { return false; } - WPin.DataType sourceType = source.getOutputs().get(connection.sourcePin()).getDataType(); - WPin.DataType targetType = target.getInputs().get(connection.targetPin()).getDataType(); - return sourceType == targetType || (sourceType == WPin.DataType.NUMBER && targetType == WPin.DataType.STRING); - } - - /** - * Moves editor spline control points for every connection whose source or target is in {@code nodeIds}. - * Call with incremental {@code dx}/{@code dy} while dragging those nodes (selection, section bundle, etc.). - */ - public void shiftWaypointsForConnectionsTouching(Collection nodeIds, int dx, int dy) { - if (nodeIds == null || nodeIds.isEmpty() || (dx == 0 && dy == 0)) { - return; + connections.removeIf(existing -> + existing.targetNode().equals(connection.targetNode()) + && existing.targetPin() == connection.targetPin()); + String sourceKey = connection.sourcePortKey(); + String targetKey = connection.targetPortKey(); + if (sourceKey == null) { + sourceKey = WNode.stablePortId(source.getOutputs(), connection.sourcePin(), "output"); } - for (int i = 0; i < connections.size(); i++) { - WConnection c = connections.get(i); - if (!nodeIds.contains(c.sourceNode()) && !nodeIds.contains(c.targetNode())) { - continue; - } - if (c.waypointXs().length == 0) { - continue; - } - int[] nxs = java.util.Arrays.copyOf(c.waypointXs(), c.waypointXs().length); - int[] nys = java.util.Arrays.copyOf(c.waypointYs(), c.waypointYs().length); - for (int j = 0; j < nxs.length; j++) { - nxs[j] += dx; - nys[j] += dy; - } - connections.set(i, c.withWaypoints(nxs, nys)); + if (targetKey == null) { + targetKey = WNode.stablePortId(target.getInputs(), connection.targetPin(), "input"); } + connections.add(connection.withStablePorts(sourceKey, targetKey)); + updateTopology(); + return true; } - /** - * Removes every connection that touches any of the given node ids (inputs and outputs). - */ - public void disconnectNodes(Collection nodeIds) { + public void disconnectNodes(Set nodeIds) { if (nodeIds == null || nodeIds.isEmpty()) { return; } - connections.removeIf( - c -> nodeIds.contains(c.sourceNode()) || nodeIds.contains(c.targetNode())); + connections.removeIf(connection -> + nodeIds.contains(connection.sourceNode()) || nodeIds.contains(connection.targetNode())); updateTopology(); } - /** - * @return An unmodifiable view of all nodes currently in the graph. - */ - public List getNodes() { - return nodes; - } - - public List getDiagnostics() { - return List.copyOf(diagnostics); - } - - public boolean isNodeExecutionDisabled(UUID nodeId) { - return disabledNodeIds.contains(nodeId); - } - - public List getSections() { - return sections; - } - - public int getSimulationStepCounter() { - return simulationStepCounter; - } - - /** Executes one detached node with graph-step services for the public API compatibility adapter. */ - static void evaluateIsolated(WNode node, long graphStep, boolean tickPulseGate) { - WGraph scope = new WGraph(); - scope.simulationStepCounter = (int) graphStep; - scope.evalTickPulseGate = tickPulseGate; - node.bindEvaluationGraph(scope); - try { - node.evaluate(); - } finally { - node.bindEvaluationGraph(null); - scope.evalTickPulseGate = false; - } - } - - /** - * While a node {@link WNode#evaluate()} runs inside this graph, returns whether this step is a tick pulse - * (same instants as the Tick node's output) or always true when the graph has no tick driver. - */ - public boolean isEvalTickPulseGate() { - return evalTickPulseGate; - } - - /** True if this graph contains a {@link #TICK_NODE_TYPE} node (stepped simulation). */ - public boolean usesTickDriver() { - for (WNode n : nodes) { - if (TICK_NODE_TYPE.equals(n.getTypeId())) { - return true; - } - } - return false; - } - - /** - * Advances simulation by {@code deltaSeconds} of wall time. - *
    - *
  • With no tick driver: propagates wires and evaluates all nodes every call (editor "live" mode). - *
  • With a tick driver: only propagates and evaluates on a pulse; tick nodes set outputs every call. - *
- */ - public void advanceSimulation(double deltaSeconds) { - if (deltaSeconds <= 0) { - deltaSeconds = 1.0e-4; - } - if (!usesTickDriver()) { - stepConnectionsAndEval(true); - simulationStepCounter++; + public void shiftWaypointsForConnectionsTouching(Collection nodeIds, int deltaX, int deltaY) { + if (nodeIds == null || nodeIds.isEmpty() || deltaX == 0 && deltaY == 0) { return; } - boolean pulse = prepareTickDrivers(deltaSeconds); - if (pulse) { - stepConnectionsAndEval(true); - simulationStepCounter++; - } - } - - /** - * Block/world execution: source nodes are polled every game tick, then only the downstream graph section - * whose driver changed (or emitted an active tick/event pulse) is propagated and evaluated. - */ - public void advanceSimulationInWorld(double deltaSeconds) { - if (deltaSeconds <= 0) { - deltaSeconds = 1.0e-4; - } - boolean tickPulse = true; - if (usesTickDriver()) { - Map tickDriverSnapshots = snapshotTickDrivers(); - tickPulse = prepareTickDrivers(deltaSeconds); - markChangedTickDrivers(tickDriverSnapshots); - } else { - forcedDirtySources.clear(); - } - if (forceFullWorldStep) { - stepConnectionsAndEval(tickPulse); - forceFullWorldStep = false; - } else { - stepSparseConnectionsAndEval(tickPulse); - } - simulationStepCounter++; - } - - /** - * @deprecated Use {@link #advanceSimulation(double)} with an appropriate delta time. - */ - @Deprecated - public void tick() { - advanceSimulation(1.0 / MAX_TICK_RATE); - } - - private boolean prepareTickDrivers(double dt) { - boolean anyPulse = false; - tickAccumSec.keySet().removeIf(id -> findNode(id) == null); - for (WNode n : nodes) { - if (!TICK_NODE_TYPE.equals(n.getTypeId())) { + for (int i = 0; i < connections.size(); i++) { + WConnection connection = connections.get(i); + if ((!nodeIds.contains(connection.sourceNode()) && !nodeIds.contains(connection.targetNode())) + || connection.waypointXs().length == 0) { continue; } - if (n.getOutputs().size() < 2) { - continue; - } - UUID id = n.getId(); - double[] acc = tickAccumSec.computeIfAbsent(id, k -> new double[1]); - acc[0] += dt; - double rate = readTickRateSlider(n); - boolean pulse = false; - if (rate > 1e-9) { - double period = 1.0 / rate; - if (acc[0] >= period) { - pulse = true; - n.getOutputs().get(1).setValue(acc[0]); - acc[0] = 0.0; - } - } - n.getOutputs().get(0).setValue(pulse ? 1.0 : 0.0); - if (!pulse) { - n.getOutputs().get(1).setValue(acc[0]); - } - anyPulse |= pulse; - } - return anyPulse; - } - - private static double readTickRateSlider(WNode n) { - for (var el : n.getElements()) { - if (el instanceof WSlider s) { - return Mth.clamp(s.getValue(), 0.0, MAX_TICK_RATE); + int[] xs = Arrays.copyOf(connection.waypointXs(), connection.waypointXs().length); + int[] ys = Arrays.copyOf(connection.waypointYs(), connection.waypointYs().length); + for (int waypoint = 0; waypoint < xs.length; waypoint++) { + xs[waypoint] += deltaX; + ys[waypoint] += deltaY; } + connections.set(i, connection.withWaypoints(xs, ys)); } - return MAX_TICK_RATE; - } - - /** - * Single propagation + evaluation pass (used by nested {@link FunctionCardNode} bodies and live preview). - * Increments {@link #getSimulationStepCounter()} afterward so nested graphs advance a logical step counter - * every time the inner graph runs. - */ - public void propagateAndEvaluate() { - stepConnectionsAndEval(true); - simulationStepCounter++; + markConnectionGeometryChanged(); } - /** One logical step: propagate all connections, then evaluate every node. */ - private void stepConnectionsAndEval(boolean tickPulseGate) { - refreshStableConnectionPins(); - evalTickPulseGate = tickPulseGate; - try { - // Seed inputs from the previous committed outputs. Evaluation then walks the compiled DAG and - // immediately forwards each new output, so pure chains settle in one deterministic pass. - propagateConnections(); - for (WNode node : evaluationOrder) { - evaluateNode(node); - if (!node.isStateBoundary()) { - propagateOutgoingValues(node); - } - } - } finally { - evalTickPulseGate = false; - } - } - - private void stepSparseConnectionsAndEval(boolean tickPulseGate) { - refreshStableConnectionPins(); - evalTickPulseGate = tickPulseGate; - ArrayDeque queue = new ArrayDeque<>(); - Set queued = new HashSet<>(); - try { - // Publish every state boundary's previously committed outputs before evaluating any pure node. - for (WNode node : evaluationOrder) { - if (!node.isStateBoundary()) continue; - if (disabledNodeIds.contains(node.getId())) resetOutputs(node); - propagateOutgoing(node, queue, queued); - } - for (WNode node : evaluationOrder) { - if (node.isStateBoundary()) continue; - List incoming = incomingConnections.get(node.getId()); - boolean always = node.executionPolicy() - != dev.propulsionteam.computed.api.node.ExecutionPolicy.INPUT_DRIVEN; - if (!always && incoming != null && !incoming.isEmpty()) { - continue; - } - if (queued.remove(node.getId())) queue.remove(node); - PinSnapshot[] before = snapshotOutputs(node); - evaluateNode(node); - if (outputsChanged(node, before) - || hasActivePulseOutput(node) - || forcedDirtySources.contains(node.getId())) { - propagateOutgoing(node, queue, queued); - } - } - - int remaining = Math.max(1, nodes.size() * Math.max(1, connections.size() + 1)); - while (!queue.isEmpty() && remaining-- > 0) { - WNode node = queue.removeFirst(); - queued.remove(node.getId()); - if (node.isStateBoundary()) continue; - PinSnapshot[] before = snapshotOutputs(node); - evaluateNode(node); - if (outputsChanged(node, before) || hasActivePulseOutput(node)) { - propagateOutgoing(node, queue, queued); - } - } - // Inputs are now settled. Compute and commit next state, but do not publish it until the next step. - for (WNode node : evaluationOrder) { - if (node.isStateBoundary()) evaluateNode(node); - } - } finally { - evalTickPulseGate = false; - } - } - - private void evaluateNode(WNode node) { - if (disabledNodeIds.contains(node.getId())) { - resetOutputs(node); - return; - } - node.bindEvaluationGraph(this); - try { - node.evaluate(); - } finally { - node.bindEvaluationGraph(null); - } + public void markConnectionGeometryChanged() { + connectionGeometryRevision++; } - private static void resetOutputs(WNode node) { - for (WPin output : node.getOutputs()) { - output.setConnected(false); - switch (output.getDataType()) { - case NUMBER -> output.setValue(0.0); - case STRING -> output.setStringValue(""); - case WIDGET -> output.setWidgetValue(null); - } - } + public void onNodePinSchemaChanged(WNode node) { + resolveStablePorts(); + updateTopology(); } - private Map snapshotTickDrivers() { - Map snapshots = new HashMap<>(); + public void updateTopology() { + connections.removeIf(connection -> !isValidConnection(connection)); + refreshConnectedPins(); + Map indegree = new HashMap<>(); + Map> outgoing = new HashMap<>(); for (WNode node : nodes) { - if (TICK_NODE_TYPE.equals(node.getTypeId())) { - snapshots.put(node.getId(), snapshotOutputs(node)); - } + indegree.put(node.getId(), 0); + outgoing.put(node.getId(), new ArrayList<>()); + node.setTopoDepth(0); } - return snapshots; - } - - private void markChangedTickDrivers(Map before) { - forcedDirtySources.clear(); - for (WNode node : nodes) { - if (!TICK_NODE_TYPE.equals(node.getTypeId())) { - continue; - } - PinSnapshot[] snapshot = before.get(node.getId()); - if (snapshot == null || outputsChanged(node, snapshot) || hasActivePulseOutput(node)) { - forcedDirtySources.add(node.getId()); + for (WConnection connection : connections) { + WNode source = getNode(connection.sourceNode()); + if (source != null && !source.isStateBoundary()) { + outgoing.get(connection.sourceNode()).add(connection.targetNode()); + indegree.computeIfPresent(connection.targetNode(), (id, degree) -> degree + 1); } } - } - - private void propagateOutgoing(WNode source, ArrayDeque queue, Set queued) { - List outgoing = outgoingConnections.get(source.getId()); - if (outgoing == null || outgoing.isEmpty()) { - return; - } - for (WConnection conn : outgoing) { - WNode target = nodeIndex.get(conn.targetNode()); - if (target == null || !copyConnectionValue(source, target, conn)) { - continue; + ArrayDeque ready = new ArrayDeque<>(); + indegree.forEach((id, degree) -> { + if (degree == 0) { + ready.add(id); } - if (target.isStateBoundary()) { - // The new input is committed for the next graph step; state boundaries run once per step. + }); + Set visited = new HashSet<>(); + while (!ready.isEmpty()) { + UUID current = ready.removeFirst(); + if (!visited.add(current)) { continue; } - if (queued.add(target.getId())) { - queue.addLast(target); + WNode source = getNode(current); + for (UUID targetId : outgoing.getOrDefault(current, List.of())) { + WNode target = getNode(targetId); + if (source != null && target != null) { + target.setTopoDepth(Math.max(target.getTopoDepth(), source.getTopoDepth() + 1)); + } + int next = indegree.computeIfPresent(targetId, (id, degree) -> degree - 1); + if (next == 0) { + ready.addLast(targetId); + } } } - } - - private void propagateOutgoingValues(WNode source) { - List outgoing = outgoingConnections.get(source.getId()); - if (outgoing == null) { - return; - } - for (WConnection connection : outgoing) { - WNode target = nodeIndex.get(connection.targetNode()); - if (target != null && !disabledNodeIds.contains(target.getId())) { - copyConnectionValue(source, target, connection); + for (WNode node : nodes) { + if (!visited.contains(node.getId())) { + node.setTopoDepth(Integer.MAX_VALUE); } } + connectionGeometryRevision++; } - private boolean copyConnectionValue(WNode source, WNode target, WConnection conn) { - int sp = conn.sourcePin(); - int tp = conn.targetPin(); - if (sp < 0 - || sp >= source.getOutputs().size() - || tp < 0 - || tp >= target.getInputs().size()) { - return false; - } - WPin srcPin = source.getOutputs().get(sp); - WPin tgtPin = target.getInputs().get(tp); - if (srcPin.getDataType() != tgtPin.getDataType()) { - if (srcPin.getDataType() == WPin.DataType.NUMBER - && tgtPin.getDataType() == WPin.DataType.STRING) { - tgtPin.setStringValue(formatNumberForString(srcPin.getValue())); - tgtPin.setConnected(true); - srcPin.setConnected(true); - return true; - } - return false; - } - switch (srcPin.getDataType()) { - case NUMBER -> tgtPin.setValue(srcPin.getValue()); - case STRING -> tgtPin.setStringValue(srcPin.getStringValue()); - case WIDGET -> tgtPin.setWidgetValue(srcPin.getWidgetValue()); + public CompoundTag save() { + CompoundTag root = new CompoundTag(); + ListTag nodeTags = new ListTag(); + for (WNode node : nodes) { + nodeTags.add(node.save()); } - tgtPin.setConnected(true); - srcPin.setConnected(true); - return true; - } - - private static boolean hasActivePulseOutput(WNode node) { - for (WPin pin : node.getOutputs()) { - if (pin.getDataType() != WPin.DataType.NUMBER || pin.getValue() <= 0.5) { - continue; - } - String name = pin.getName(); - if ("Tick".equalsIgnoreCase(name) || "Event".equalsIgnoreCase(name)) { - return true; + root.put("nodes", nodeTags); + ListTag connectionTags = new ListTag(); + for (WConnection connection : connections) { + CompoundTag tag = new CompoundTag(); + tag.putString("src", connection.sourceNode().toString()); + tag.putInt("srcP", connection.sourcePin()); + tag.putString("tgt", connection.targetNode().toString()); + tag.putInt("tgtP", connection.targetPin()); + if (connection.sourcePortKey() != null) { + tag.putString("srcKey", connection.sourcePortKey()); } - } - return false; - } - - private static PinSnapshot[] snapshotOutputs(WNode node) { - PinSnapshot[] out = new PinSnapshot[node.getOutputs().size()]; - for (int i = 0; i < out.length; i++) { - out[i] = PinSnapshot.capture(node.getOutputs().get(i)); - } - return out; - } - - private static boolean outputsChanged(WNode node, PinSnapshot[] before) { - if (before.length != node.getOutputs().size()) { - return true; - } - for (int i = 0; i < before.length; i++) { - if (!before[i].matches(node.getOutputs().get(i))) { - return true; + if (connection.targetPortKey() != null) { + tag.putString("tgtKey", connection.targetPortKey()); } - } - return false; - } - - private record PinSnapshot(WPin.DataType type, double numberValue, String stringValue, Object widgetValue) { - static PinSnapshot capture(WPin pin) { - return new PinSnapshot(pin.getDataType(), pin.getValue(), pin.getStringValue(), pin.getWidgetValue()); - } - - boolean matches(WPin pin) { - if (pin.getDataType() != type) { - return false; + ListTag waypoints = new ListTag(); + for (int i = 0; i < connection.waypointXs().length; i++) { + CompoundTag waypoint = new CompoundTag(); + waypoint.putInt("x", connection.waypointXs()[i]); + waypoint.putInt("y", connection.waypointYs()[i]); + waypoints.add(waypoint); } - return switch (type) { - case NUMBER -> Double.compare(numberValue, pin.getValue()) == 0; - case STRING -> stringValue.equals(pin.getStringValue()); - case WIDGET -> widgetValue == pin.getWidgetValue(); - }; + tag.put("waypoints", waypoints); + connectionTags.add(tag); } + root.put("connections", connectionTags); + return root; } - private static String formatNumberForString(double v) { - if (Math.abs(v - Math.rint(v)) < 1.0e-9 && Math.abs(v) < 1e15) { - return Long.toString((long) Math.rint(v)); - } - return Double.toString(v); - } - - private void propagateConnections() { + public void load(CompoundTag root) { for (WNode node : nodes) { - for (WPin pin : node.getInputs()) { - pin.setConnected(false); - switch (pin.getDataType()) { - case NUMBER -> pin.setValue(0.0); - case STRING -> pin.setStringValue(""); - case WIDGET -> pin.setWidgetValue(null); - } - } - for (WPin pin : node.getOutputs()) { - pin.setConnected(false); - } - if (disabledNodeIds.contains(node.getId())) { - resetOutputs(node); - } + node.bindOwningGraph(null); } - for (WConnection conn : connections) { - WNode source = nodeIndex.get(conn.sourceNode()); - WNode target = nodeIndex.get(conn.targetNode()); - if (source == null || target == null) { - continue; - } - int sp = conn.sourcePin(); - int tp = conn.targetPin(); - if (sp < 0 - || sp >= source.getOutputs().size() - || tp < 0 - || tp >= target.getInputs().size()) { + nodes.clear(); + nodeIndex.clear(); + connections.clear(); + ListTag nodeTags = root.getList("nodes", 10); + for (int i = 0; i < nodeTags.size(); i++) { + CompoundTag tag = nodeTags.getCompound(i); + UUID id; + try { + id = UUID.fromString(tag.getString("id")); + } catch (IllegalArgumentException ignored) { continue; } - WPin srcPin = source.getOutputs().get(sp); - WPin tgtPin = target.getInputs().get(tp); - if (srcPin.getDataType() != tgtPin.getDataType()) { - if (srcPin.getDataType() == WPin.DataType.NUMBER - && tgtPin.getDataType() == WPin.DataType.STRING) { - tgtPin.setStringValue(formatNumberForString(srcPin.getValue())); - tgtPin.setConnected(true); - srcPin.setConnected(true); - } + WNode node = nodeArchive.get(id); + if (node == null) { continue; } - switch (srcPin.getDataType()) { - case NUMBER -> tgtPin.setValue(srcPin.getValue()); - case STRING -> tgtPin.setStringValue(srcPin.getStringValue()); - case WIDGET -> tgtPin.setWidgetValue(srcPin.getWidgetValue()); - } - tgtPin.setConnected(true); - srcPin.setConnected(true); - } - } - - /** - * Internal helper to find a node by its unique identifier. - * @param id UUID of the node. - * @return The node instance or null if not found. - */ - private WNode findNode(UUID id) { - return id == null ? null : nodeIndex.get(id); - } - - /** - * Updates the topological structure of the graph. - * Assigns each node a depth starting from roots (no incoming connections). Depth is used for animation - * sync; it is capped at {@code nodes.size() - 1} so feedback cycles cannot drive unbounded growth (which - * would hang this BFS). - */ - public void updateTopology() { - rebuildConnectionIndexes(); - analyzeCombinationalCycles(); - rebuildEvaluationOrder(); - forceFullWorldStep = true; - - // Reset depths - for (WNode node : nodes) node.setTopoDepth(-1); - - java.util.Queue queue = new java.util.LinkedList<>(); - - // Find roots (nodes with no connected inputs) - for (WNode node : nodes) { - boolean hasInputs = false; - for (WConnection conn : connections) { - if (conn.targetNode().equals(node.getId())) { - hasInputs = true; - break; + node.load(tag); + node.bindOwningGraph(this); + nodes.add(node); + nodeIndex.put(id, node); + } + ListTag connectionTags = root.getList("connections", 10); + for (int i = 0; i < connectionTags.size(); i++) { + CompoundTag tag = connectionTags.getCompound(i); + try { + ListTag waypoints = tag.getList("waypoints", 10); + int[] xs = new int[waypoints.size()]; + int[] ys = new int[waypoints.size()]; + for (int waypoint = 0; waypoint < waypoints.size(); waypoint++) { + xs[waypoint] = waypoints.getCompound(waypoint).getInt("x"); + ys[waypoint] = waypoints.getCompound(waypoint).getInt("y"); } - } - if (!hasInputs) { - node.setTopoDepth(0); - queue.add(node); - } - } - - // BFS to propagate depth (longest-ish path from roots). Capped so cycles cannot grow depth forever - // (otherwise the queue never empties and the client/server hangs on every connect). - int maxDepth = Math.max(0, nodes.size() - 1); - while (!queue.isEmpty()) { - WNode current = queue.poll(); - int nextDepth = Math.min(current.getTopoDepth() + 1, maxDepth); - - for (WConnection conn : connections) { - if (conn.sourceNode().equals(current.getId())) { - WNode target = nodeIndex.get(conn.targetNode()); - if (target != null && (target.getTopoDepth() == -1 || target.getTopoDepth() < nextDepth)) { - target.setTopoDepth(nextDepth); - queue.add(target); - } + WConnection connection = new WConnection( + UUID.fromString(tag.getString("src")), + tag.getInt("srcP"), + UUID.fromString(tag.getString("tgt")), + tag.getInt("tgtP"), + xs, + ys, + tag.contains("srcKey") ? tag.getString("srcKey") : null, + tag.contains("tgtKey") ? tag.getString("tgtKey") : null); + if (isValidConnection(connection)) { + connections.add(connection); } + } catch (IllegalArgumentException ignored) { } } - - // Handle remaining nodes (those in cycles with no external roots) - for (WNode node : nodes) { - if (node.getTopoDepth() == -1) node.setTopoDepth(0); - } - } - - private void rebuildConnectionIndexes() { - outgoingConnections.clear(); - incomingConnections.clear(); - for (WNode node : nodes) { - outgoingConnections.put(node.getId(), new ArrayList<>()); - incomingConnections.put(node.getId(), new ArrayList<>()); - } - for (WConnection conn : connections) { - if (!isConnectionUsable(conn)) continue; - List out = outgoingConnections.get(conn.sourceNode()); - List in = incomingConnections.get(conn.targetNode()); - if (out == null || in == null) { - continue; - } - out.add(conn); - in.add(conn); - } - } - - private void analyzeCombinationalCycles() { - disabledNodeIds.clear(); - diagnostics.clear(); - Map indexes = new HashMap<>(); - Map lowLinks = new HashMap<>(); - ArrayDeque stack = new ArrayDeque<>(); - Set onStack = new HashSet<>(); - int[] nextIndex = {0}; - for (WNode node : nodes) { - if (node.isMissingType()) { - disabledNodeIds.add(node.getId()); - diagnostics.add(new GraphDiagnostic( - DiagnosticSeverity.ERROR, - "computed.missing_node", - "Node implementation is unavailable; raw data is preserved", - Set.of(node.getId()))); - } - if (!indexes.containsKey(node.getId())) { - strongConnect(node.getId(), indexes, lowLinks, stack, onStack, nextIndex); - } - } + resolveStablePorts(); + updateTopology(); } - private void rebuildEvaluationOrder() { - evaluationOrder.clear(); - Map indegree = new HashMap<>(); - Map> adjacency = new HashMap<>(); + private void resolveStablePorts() { + for (WConnection connection : connections) { + WNode source = getNode(connection.sourceNode()); + WNode target = getNode(connection.targetNode()); + int sourcePin = portIndex(source == null ? List.of() : source.getOutputs(), connection.sourcePortKey()); + int targetPin = portIndex(target == null ? List.of() : target.getInputs(), connection.targetPortKey()); + connection.resolvePins( + sourcePin >= 0 ? sourcePin : connection.sourcePin(), + targetPin >= 0 ? targetPin : connection.targetPin()); + } + } + + private boolean isValidConnection(WConnection connection) { + WNode source = getNode(connection.sourceNode()); + WNode target = getNode(connection.targetNode()); + return source != null + && target != null + && connection.sourcePin() >= 0 + && connection.sourcePin() < source.getOutputs().size() + && connection.targetPin() >= 0 + && connection.targetPin() < target.getInputs().size() + && source.getOutputs().get(connection.sourcePin()).getDataType() + == target.getInputs().get(connection.targetPin()).getDataType(); + } + + private void refreshConnectedPins() { for (WNode node : nodes) { - if (!disabledNodeIds.contains(node.getId())) { - indegree.put(node.getId(), 0); - adjacency.put(node.getId(), new HashSet<>()); - } + node.getInputs().forEach(pin -> pin.setConnected(false)); + node.getOutputs().forEach(pin -> pin.setConnected(false)); } for (WConnection connection : connections) { - if (!isConnectionUsable(connection)) continue; - WNode source = nodeIndex.get(connection.sourceNode()); - WNode target = nodeIndex.get(connection.targetNode()); - if (source == null - || target == null - || source.isStateBoundary() - || !indegree.containsKey(source.getId()) - || !indegree.containsKey(target.getId())) { - continue; - } - if (adjacency.get(source.getId()).add(target.getId())) { - indegree.merge(target.getId(), 1, Integer::sum); - } - } - java.util.PriorityQueue ready = new java.util.PriorityQueue<>(); - indegree.forEach((id, degree) -> { - if (degree == 0) ready.add(id); - }); - while (!ready.isEmpty()) { - UUID id = ready.remove(); - WNode node = nodeIndex.get(id); - if (node != null) evaluationOrder.add(node); - for (UUID target : adjacency.getOrDefault(id, Set.of())) { - int remaining = indegree.merge(target, -1, Integer::sum); - if (remaining == 0) ready.add(target); + WNode source = getNode(connection.sourceNode()); + WNode target = getNode(connection.targetNode()); + if (source != null && target != null) { + source.getOutputs().get(connection.sourcePin()).setConnected(true); + target.getInputs().get(connection.targetPin()).setConnected(true); } } } - private void strongConnect( - UUID nodeId, - Map indexes, - Map lowLinks, - ArrayDeque stack, - Set onStack, - int[] nextIndex) { - int index = nextIndex[0]++; - indexes.put(nodeId, index); - lowLinks.put(nodeId, index); - stack.push(nodeId); - onStack.add(nodeId); - - List outgoing = outgoingConnections.get(nodeId); - WNode sourceNode = nodeIndex.get(nodeId); - if (outgoing != null && (sourceNode == null || !sourceNode.isStateBoundary())) { - for (WConnection connection : outgoing) { - WNode target = nodeIndex.get(connection.targetNode()); - if (target == null) { - continue; - } - UUID targetId = target.getId(); - if (!indexes.containsKey(targetId)) { - strongConnect(targetId, indexes, lowLinks, stack, onStack, nextIndex); - lowLinks.put(nodeId, Math.min(lowLinks.get(nodeId), lowLinks.get(targetId))); - } else if (onStack.contains(targetId)) { - lowLinks.put(nodeId, Math.min(lowLinks.get(nodeId), indexes.get(targetId))); - } - } - } - - if (!lowLinks.get(nodeId).equals(indexes.get(nodeId))) { - return; + private static int portIndex(List pins, String stableKey) { + if (stableKey == null) { + return -1; } - Set component = new HashSet<>(); - UUID member; - do { - member = stack.pop(); - onStack.remove(member); - component.add(member); - } while (!member.equals(nodeId)); - - boolean selfLoop = component.size() == 1 && connections.stream().anyMatch( - connection -> isConnectionUsable(connection) - && connection.sourceNode().equals(nodeId) - && connection.targetNode().equals(nodeId) - && !nodeIndex.get(nodeId).isStateBoundary()); - if (component.size() > 1 || selfLoop) { - disabledNodeIds.addAll(component); - diagnostics.add(new GraphDiagnostic( - DiagnosticSeverity.ERROR, - "computed.cycle.legacy", - "Legacy combinational cycle is preserved but disabled; insert a state or delay node", - Set.copyOf(component))); + for (int i = 0; i < pins.size(); i++) { + if (stableKey.equals(pins.get(i).getStableKey())) { + return i; + } } - } - - /** - * @return A list of all connections in the graph. - */ - public List getConnections() { - refreshStableConnectionPins(); - return connections; + return -1; } } diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/WGraphSerializer.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/WGraphSerializer.java deleted file mode 100644 index 80f4ba0..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/WGraphSerializer.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.TagParser; -import java.util.Base64; - -public class WGraphSerializer { - - /** - * Converts a graph to a Base64 encoded NBT string. - */ - public static String serializeToBase64(WGraph graph) { - CompoundTag tag = graph.save(); - return Base64.getEncoder().encodeToString(tag.toString().getBytes()); - } - - /** - * Loads a graph from a Base64 encoded NBT string. - */ - public static void deserializeFromBase64(WGraph graph, String base64) { - try { - String decoded = new String(Base64.getDecoder().decode(base64)); - CompoundTag tag = TagParser.parseTag(decoded); - graph.load(tag); - } catch (Exception e) { - e.printStackTrace(); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/WNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/WNode.java index 37e7ec5..b3babb9 100644 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/WNode.java +++ b/src/main/java/dev/propulsionteam/computed/internal/node/api/WNode.java @@ -1,253 +1,104 @@ package dev.propulsionteam.computed.internal.node.api; -import dev.propulsionteam.computed.api.node.ExecutionPolicy; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.resources.ResourceLocation; -import net.neoforged.fml.loading.FMLEnvironment; - +import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.fml.loading.FMLEnvironment; -/** - * Represents an individual node within the graph. - * A node can have inputs, outputs, UI elements, and a custom evaluation logic. - */ public class WNode { private static final int PIN_SIZE = 5; private static final int PIN_HOVER_SIZE = 7; - private UUID id; + private UUID id = UUID.randomUUID(); private final ResourceLocation typeId; private String title; - private int x, y; + private int x; + private int y; private int width = 120; private int height = 40; - - private final List elements = new ArrayList<>(); + private int topoDepth; + private boolean selected; + private boolean layoutDirty = true; private final List inputs = new ArrayList<>(); private final List outputs = new ArrayList<>(); - private Evaluator evaluator = (node) -> {}; - private int topoDepth = 0; - private boolean selected = false; - /** Cached layout left-margin (input-label area + padding); recomputed in {@link #updateLayout()}. */ - private int leftMargin = 5; - /** True when pins / elements / title changed since last {@link #updateLayout()}. */ - private boolean layoutDirty = true; - /** Monotonic schema generation used by WGraph to remap stable connection keys lazily. */ - private long pinSchemaRevision; private transient WGraph owningGraph; - /** Called by {@link WElement#markLayoutDirty()} when a child element's measured size changes. */ - public void markLayoutDirty() { - this.layoutDirty = true; - } - - public void markPinSchemaChanged() { - pinSchemaRevision++; - layoutDirty = true; - if (owningGraph != null) owningGraph.onNodePinSchemaChanged(this); - } - - public long getPinSchemaRevision() { - return pinSchemaRevision; - } - - void bindOwningGraph(WGraph graph) { - owningGraph = graph; - } - - private void ensureLayout() { - if (layoutDirty) { - updateLayout(); - layoutDirty = false; - } - } - - /** Makes bounds current without rendering this node or any of its elements. */ - public final void ensureLayoutUpToDate() { - ensureLayout(); - } - /** Non-null only while {@link #evaluate()} runs as part of a {@link WGraph} step. */ - private transient WGraph evaluationGraph; - - /** - * Interface for custom node behavior. - * The evaluate method is called every graph tick. - */ - public interface Evaluator { - void evaluate(WNode node); - } - - /** - * Sets the custom logic for this node. - * @param evaluator A lambda or class implementing the logic. - */ - public void setEvaluator(Evaluator evaluator) { - this.evaluator = evaluator; - } - - /** - * Executes the node's custom logic. - */ - public void evaluate() { - this.evaluator.evaluate(this); - } - - /** @see WGraph#isEvalTickPulseGate() */ - public WGraph evaluationGraph() { - return evaluationGraph; - } - - void bindEvaluationGraph(WGraph graph) { - this.evaluationGraph = graph; - } - - /** - * Creates a new node instance. - * @param typeId Unique identifier for the node type. - * @param title Display title of the node. - * @param x Initial X coordinate in logical space. - * @param y Initial Y coordinate in logical space. - */ public WNode(ResourceLocation typeId, String title, int x, int y) { - this.id = UUID.randomUUID(); this.typeId = typeId; - this.title = title; + this.title = title == null ? "" : title; this.x = x; this.y = y; } - /** - * Adds a UI element (slider, button, text field, etc.) to the node body. - * @param element The element to add. - */ - public void addElement(WElement element) { - this.elements.add(element); - element.parent = this; - layoutDirty = true; - } - - /** - * Adds an input pin to the left side of the node. - * @param name Name of the input. - * @param color Display color of the pin. - */ public void addInput(String name, int color) { - WPin pin = new WPin(name, WPin.Type.INPUT, color); - this.inputs.add(pin); - markPinSchemaChanged(); + addInput(null, name, WPin.DataType.NUMBER, color); } - /** Typed input pin. */ public void addInput(String name, WPin.DataType dataType, int color) { - this.inputs.add(new WPin(name, WPin.Type.INPUT, dataType, color)); - markPinSchemaChanged(); + addInput(null, name, dataType, color); } - /** Typed input with an explicit stable persistence key. */ public void addInput(String stableKey, String name, WPin.DataType dataType, int color) { - this.inputs.add(new WPin(stableKey, name, WPin.Type.INPUT, dataType, color)); + inputs.add(new WPin(stableKey, name, WPin.Type.INPUT, dataType, color)); markPinSchemaChanged(); } - /** - * Adds an output pin to the right side of the node. - * @param name Name of the output. - * @param color Display color of the pin. - */ public void addOutput(String name, int color) { - WPin pin = new WPin(name, WPin.Type.OUTPUT, color); - this.outputs.add(pin); - markPinSchemaChanged(); + addOutput(null, name, WPin.DataType.NUMBER, color); } - /** Typed output pin. */ public void addOutput(String name, WPin.DataType dataType, int color) { - this.outputs.add(new WPin(name, WPin.Type.OUTPUT, dataType, color)); - markPinSchemaChanged(); + addOutput(null, name, dataType, color); } - /** Typed output with an explicit stable persistence key. */ public void addOutput(String stableKey, String name, WPin.DataType dataType, int color) { - this.outputs.add(new WPin(stableKey, name, WPin.Type.OUTPUT, dataType, color)); + outputs.add(new WPin(stableKey, name, WPin.Type.OUTPUT, dataType, color)); markPinSchemaChanged(); } - /** - * Recalculates the node's dimensions based on its pins, elements, and title. - * Automatically adjusts the width and height for a clean look. - */ - private static int measureTextWidth(String text) { - if (FMLEnvironment.dist.isDedicatedServer()) { - return Math.max(8, text.length() * 6); - } - return net.minecraft.client.Minecraft.getInstance().font.width(text); + public void markLayoutDirty() { + layoutDirty = true; } - public void updateLayout() { - int headerHeight = 16; - int maxInputLabelWidth = 0; - for (WPin pin : inputs) { - maxInputLabelWidth = Math.max(maxInputLabelWidth, measureTextWidth(pin.getName())); - } - - int maxOutputLabelWidth = 0; - for (WPin pin : outputs) { - maxOutputLabelWidth = Math.max(maxOutputLabelWidth, measureTextWidth(pin.getName())); + public void markPinSchemaChanged() { + layoutDirty = true; + if (owningGraph != null) { + owningGraph.onNodePinSchemaChanged(this); } + } - this.leftMargin = maxInputLabelWidth > 0 ? maxInputLabelWidth + 15 : 5; - int rightMargin = maxOutputLabelWidth > 0 ? maxOutputLabelWidth + 15 : 5; + void bindOwningGraph(WGraph graph) { + owningGraph = graph; + } - int bodyWidth = 80; - int bodyHeight = 0; - for (WElement element : elements) { - bodyWidth = Math.max(bodyWidth, element.getWidth()); - bodyHeight += element.getHeight(); + public final void ensureLayoutUpToDate() { + if (layoutDirty) { + updateLayout(); } + } - int titleWidth = measureTextWidth(title) + 20; - this.width = Math.max(titleWidth, this.leftMargin + bodyWidth + rightMargin); - - int pinAreaHeight = Math.max(inputs.size(), outputs.size()) * 12; - this.height = headerHeight + Math.max(bodyHeight, pinAreaHeight) + 5; + public void updateLayout() { + int left = inputs.stream().mapToInt(pin -> measureTextWidth(pin.getName())).max().orElse(0); + int right = outputs.stream().mapToInt(pin -> measureTextWidth(pin.getName())).max().orElse(0); + width = Math.max(measureTextWidth(title) + 20, Math.max(96, left + right + 44)); + height = 21 + Math.max(inputs.size(), outputs.size()) * 12; + layoutDirty = false; } - /** - * Renders the node, its pins, and all internal elements. - * @param graphics The GuiGraphics context. - * @param mouseX Transformed mouse X coordinate. - * @param mouseY Transformed mouse Y coordinate. - * @param partialTick Animation frame fraction. - */ public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - ensureLayout(); - boolean isHovered = mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - - graphics.fill(x, y, x + width, y + height, ComputedEditorTheme.nodeBody(isHovered, selected, false)); - - // Pathmind-style tinted header with Computed's established green identity. + ensureLayoutUpToDate(); + boolean hovered = mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + graphics.fill(x, y, x + width, y + height, ComputedEditorTheme.nodeBody(hovered, selected, false)); graphics.fill(x + 1, y + 1, x + width - 1, y + 14, ComputedEditorTheme.ACCENT_HEADER); - - graphics.drawString( - net.minecraft.client.Minecraft.getInstance().font, - title, - x + 5, - y + 3, - ComputedEditorTheme.TEXT_HEADER, - false); - - // Render elements - int currentY = y + 20; - for (WElement element : elements) { - element.render(graphics, x + this.leftMargin, currentY, mouseX, mouseY, partialTick); - currentY += element.getHeight(); - } - - // Render pins + graphics.drawString(Minecraft.getInstance().font, title, x + 5, y + 3, ComputedEditorTheme.TEXT_HEADER, false); for (int i = 0; i < inputs.size(); i++) { renderPin(graphics, x - 4, y + 18 + i * 12, inputs.get(i), true, mouseX, mouseY); } @@ -256,258 +107,246 @@ public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTi } } - /** - * Internal method to render a single pin with hover effects and labels. - */ - private void renderPin(GuiGraphics graphics, int px, int py, WPin pin, boolean isInput, int mouseX, int mouseY) { - int color = pin.getColor(); - boolean hover = mouseX >= px - 1 && mouseX <= px + PIN_SIZE && mouseY >= py - 1 && mouseY <= py + PIN_SIZE; - int size = hover ? PIN_HOVER_SIZE : PIN_SIZE; - int left = px - (size - PIN_SIZE) / 2; - int top = py - (size - PIN_SIZE) / 2; - - graphics.fill( - left, - top, - left + size, - top + size, - pin.isConnected() || hover ? color : (color & 0x66FFFFFF)); - ComputedEditorStyle.drawPixelOutline( - graphics, - left, - top, - size, - size, - hover ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.SOCKET_BORDER); - int centerLeft = left + Math.max(1, size / 2 - 1); - int centerTop = top + Math.max(1, size / 2 - 1); - graphics.fill(centerLeft, centerTop, centerLeft + 2, centerTop + 2, ComputedEditorTheme.SOCKET_CENTER); - - // Pin Label - String name = pin.getName(); - int tx = isInput ? px + 8 : px - 4 - measureTextWidth(name); - graphics.drawString( - net.minecraft.client.Minecraft.getInstance().font, - name, - tx, - py - 2, - ComputedEditorTheme.TEXT_SECONDARY, - false); - } - - /** - * Checks if a specific screen position hits a pin on this node. - * @param px Local X coordinate. - * @param py Local Y coordinate. - * @param isInput True to check inputs, false for outputs. - * @return The index of the pin hit, or -1 if none. - */ - public int getPinAt(int px, int py, boolean isInput) { - int startX = isInput ? -4 : width - 1; - List list = isInput ? inputs : outputs; - for (int i = 0; i < list.size(); i++) { - int rx = startX; - int ry = 18 + i * 12; - if (px >= rx - 1 && px <= rx + PIN_SIZE && py >= ry - 1 && py <= ry + PIN_SIZE) { + public int getPinAt(int px, int py, boolean input) { + int startX = input ? -4 : width - 1; + List pins = input ? inputs : outputs; + for (int i = 0; i < pins.size(); i++) { + int pinY = 18 + i * 12; + if (px >= startX - 1 && px <= startX + PIN_SIZE && py >= pinY - 1 && py <= pinY + PIN_SIZE) { return i; } } return -1; } - /** - * Forwards mouse click events to internal UI elements. - */ public boolean mouseClicked(double mouseX, double mouseY, int button) { - ensureLayout(); - boolean handled = false; - int currentY = 20; - for (WElement element : new ArrayList<>(elements)) { - if (element.handleMouseClick(mouseX - this.leftMargin, mouseY - currentY, button)) { - handled = true; - } - currentY += element.getHeight(); - } - return handled; + return false; } - /** - * Forwards mouse release events to internal UI elements. - */ public boolean mouseReleased(double mouseX, double mouseY, int button) { - ensureLayout(); - int currentY = 20; - for (WElement element : new ArrayList<>(elements)) { - element.handleMouseRelease(mouseX - this.leftMargin, mouseY - currentY, button); - currentY += element.getHeight(); - } return false; } - /** - * Forwards key press events to internal UI elements. - */ + public boolean mouseDragged( + double mouseX, + double mouseY, + int button, + double dragX, + double dragY) { + return false; + } + + public boolean hasInteractiveElementAt(double mouseX, double mouseY) { + return false; + } + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { - for (WElement element : new ArrayList<>(elements)) { - if (element.handleKeyPress(keyCode, scanCode, modifiers)) return true; - } return false; } - /** - * Forwards character typing events to internal UI elements. - */ public boolean charTyped(char codePoint, int modifiers) { - for (WElement element : new ArrayList<>(elements)) { - if (element.handleCharTyped(codePoint, modifiers)) return true; - } return false; } - // Standard Getters and Setters - public List getElements() { return elements; } public boolean hasFocusedElement() { - for (WElement element : new ArrayList<>(elements)) { - if (element.isFocused()) { - return true; - } - } return false; } - public List getInputs() { return inputs; } - public List getOutputs() { return outputs; } - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = new net.minecraft.nbt.CompoundTag(); + public void clearElementFocus() {} + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); tag.putString("typeId", typeId.toString()); tag.putString("id", id.toString()); tag.putString("title", title); tag.putInt("x", x); tag.putInt("y", y); - - net.minecraft.nbt.ListTag inputsTag = new net.minecraft.nbt.ListTag(); + ListTag inputTags = new ListTag(); for (int i = 0; i < inputs.size(); i++) { WPin pin = inputs.get(i); - if (pin.getStableKey() == null) pin.setStableKey(stablePortId(inputs, i, "input")); - inputsTag.add(pin.save()); + if (pin.getStableKey() == null) { + pin.setStableKey(stablePortId(inputs, i, "input")); + } + inputTags.add(pin.save()); } - tag.put("inputs", inputsTag); - - net.minecraft.nbt.ListTag outputsTag = new net.minecraft.nbt.ListTag(); + tag.put("inputs", inputTags); + ListTag outputTags = new ListTag(); for (int i = 0; i < outputs.size(); i++) { WPin pin = outputs.get(i); - if (pin.getStableKey() == null) pin.setStableKey(stablePortId(outputs, i, "output")); - outputsTag.add(pin.save()); + if (pin.getStableKey() == null) { + pin.setStableKey(stablePortId(outputs, i, "output")); + } + outputTags.add(pin.save()); } - tag.put("outputs", outputsTag); - - net.minecraft.nbt.ListTag elementsTag = new net.minecraft.nbt.ListTag(); - for (WElement el : elements) elementsTag.add(el.save()); - tag.put("elements", elementsTag); - + tag.put("outputs", outputTags); return tag; } + public void load(CompoundTag tag) { + if (tag.contains("id")) { + id = UUID.fromString(tag.getString("id")); + } + if (tag.contains("title")) { + title = tag.getString("title"); + } + x = tag.getInt("x"); + y = tag.getInt("y"); + ListTag inputTags = tag.getList("inputs", 10); + for (int i = 0; i < Math.min(inputs.size(), inputTags.size()); i++) { + inputs.get(i).load(inputTags.getCompound(i)); + } + ListTag outputTags = tag.getList("outputs", 10); + for (int i = 0; i < Math.min(outputs.size(), outputTags.size()); i++) { + outputs.get(i).load(outputTags.getCompound(i)); + } + layoutDirty = true; + } + static String stablePortId(List pins, int index, String direction) { String explicit = pins.get(index).getStableKey(); - if (explicit != null && !explicit.isBlank()) return explicit; - String label = pins.get(index).getName().toLowerCase(java.util.Locale.ROOT) + if (explicit != null) { + return explicit; + } + String label = pins.get(index).getName().toLowerCase(Locale.ROOT) .replaceAll("[^a-z0-9_.-]+", "_") .replaceAll("^[^a-z]+", "") .replaceAll("_+$", ""); - if (label.isEmpty()) label = "port"; + if (label.isEmpty()) { + label = "port"; + } String base = direction + "." + label; int duplicate = 0; for (int i = 0; i <= index; i++) { - String otherExplicit = pins.get(i).getStableKey(); - if (base.equals(otherExplicit)) { - duplicate++; - continue; - } - String other = pins.get(i).getName().toLowerCase(java.util.Locale.ROOT) + String other = pins.get(i).getName().toLowerCase(Locale.ROOT) .replaceAll("[^a-z0-9_.-]+", "_") .replaceAll("^[^a-z]+", "") .replaceAll("_+$", ""); - if (other.isEmpty()) other = "port"; - if (other.equals(label)) duplicate++; + if (other.isEmpty()) { + other = "port"; + } + if (other.equals(label)) { + duplicate++; + } } return duplicate <= 1 ? base : base + "." + duplicate; } - public void load(net.minecraft.nbt.CompoundTag tag) { - if (tag.contains("id")) { - this.id = UUID.fromString(tag.getString("id")); - } - if (tag.contains("title")) { - this.title = tag.getString("title"); - } - this.x = tag.getInt("x"); - this.y = tag.getInt("y"); - - net.minecraft.nbt.ListTag inputsTag = tag.getList("inputs", 10); - for (int i = 0; i < Math.min(inputs.size(), inputsTag.size()); i++) inputs.get(i).load(inputsTag.getCompound(i)); - - net.minecraft.nbt.ListTag outputsTag = tag.getList("outputs", 10); - for (int i = 0; i < Math.min(outputs.size(), outputsTag.size()); i++) outputs.get(i).load(outputsTag.getCompound(i)); - - net.minecraft.nbt.ListTag elementsTag = tag.getList("elements", 10); - for (int i = 0; i < Math.min(elements.size(), elementsTag.size()); i++) elements.get(i).load(elementsTag.getCompound(i)); - layoutDirty = true; + public List getInputs() { + return inputs; } - public void setSelected(boolean selected) { this.selected = selected; } - public boolean isSelected() { return selected; } + public List getOutputs() { + return outputs; + } - public void setWidth(int width) { this.width = width; } - public void setHeight(int height) { this.height = height; } + public UUID getId() { + return id; + } + + public ResourceLocation getTypeId() { + return typeId; + } - public UUID getId() { return id; } - public ResourceLocation getTypeId() { return typeId; } public String getTitle() { return title; } public void setTitle(String title) { - this.title = title != null ? title : ""; + this.title = title == null ? "" : title; layoutDirty = true; } - /** When true, editor actions cannot remove this node (selection delete, section bulk delete, etc.). */ - public boolean isDeletionLocked() { - return false; + public int getX() { + return x; } - /** When true, duplicate / clipboard duplicate skips this node. */ - public boolean isDuplicationLocked() { - return false; + public int getY() { + return y; } - public void clearElementFocus() { - for (WElement element : new ArrayList<>(elements)) { - element.clearFocus(); - } + + public void setPos(int x, int y) { + this.x = x; + this.y = y; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public void setWidth(int width) { + this.width = width; + } + + public void setHeight(int height) { + this.height = height; + } + + protected final void setMeasuredSize(int width, int height) { + this.width = width; + this.height = height; + layoutDirty = false; + } + + public int getTopoDepth() { + return topoDepth; + } + + public void setTopoDepth(int topoDepth) { + this.topoDepth = topoDepth; + } + + public boolean isSelected() { + return selected; + } + + public void setSelected(boolean selected) { + this.selected = selected; } - /** - * A state boundary publishes prior-step state and therefore breaks combinational dependency cycles. - * Stateful built-ins and data-driven nodes override this in the rewritten runtime. - */ public boolean isStateBoundary() { return false; } - /** Scheduling policy used by the dirty-propagation runtime. */ - public ExecutionPolicy executionPolicy() { - return ExecutionPolicy.INPUT_DRIVEN; + private void renderPin( + GuiGraphics graphics, + int pinX, + int pinY, + WPin pin, + boolean input, + int mouseX, + int mouseY) { + boolean hovered = mouseX >= pinX - 1 + && mouseX <= pinX + PIN_SIZE + && mouseY >= pinY - 1 + && mouseY <= pinY + PIN_SIZE; + int size = hovered ? PIN_HOVER_SIZE : PIN_SIZE; + int left = pinX - (size - PIN_SIZE) / 2; + int top = pinY - (size - PIN_SIZE) / 2; + int color = pin.getColor(); + graphics.fill(left, top, left + size, top + size, pin.isConnected() || hovered ? color : color & 0x66FFFFFF); + ComputedEditorStyle.drawPixelOutline( + graphics, + left, + top, + size, + size, + hovered ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.SOCKET_BORDER); + int centerLeft = left + Math.max(1, size / 2 - 1); + int centerTop = top + Math.max(1, size / 2 - 1); + graphics.fill(centerLeft, centerTop, centerLeft + 2, centerTop + 2, ComputedEditorTheme.SOCKET_CENTER); + String name = pin.getName(); + int textX = input ? pinX + 8 : pinX - 4 - measureTextWidth(name); + graphics.drawString(Minecraft.getInstance().font, name, textX, pinY - 2, ComputedEditorTheme.TEXT_SECONDARY, false); } - public boolean isMissingType() { - return false; + private static int measureTextWidth(String text) { + if (FMLEnvironment.dist.isDedicatedServer()) { + return Math.max(8, text.length() * 6); + } + return Minecraft.getInstance().font.width(text); } - public int getX() { return x; } - public int getY() { return y; } - public void setPos(int x, int y) { this.x = x; this.y = y; } - public int getWidth() { return width; } - public int getHeight() { return height; } - public int getTopoDepth() { return topoDepth; } - public void setTopoDepth(int depth) { this.topoDepth = depth; } } diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/WPin.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/WPin.java index 4b7d367..5c65037 100644 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/WPin.java +++ b/src/main/java/dev/propulsionteam/computed/internal/node/api/WPin.java @@ -1,41 +1,33 @@ package dev.propulsionteam.computed.internal.node.api; -import net.minecraft.client.gui.GuiGraphics; - -/** - * Represents a connection point on a node. - * Pins can be of type INPUT or OUTPUT and carry a typed value (number, string, or widget). - */ -public class WPin extends WElement { - /** Direction of data flow. */ - public enum Type { INPUT, OUTPUT } - - /** - * The kind of value carried over the pin. Connections are only allowed between matching data types. - */ - public enum DataType { NUMBER, STRING, WIDGET } - - /** Default editor accent for each data type when callers don't specify one. */ +import java.util.Locale; +import net.minecraft.nbt.CompoundTag; + +public final class WPin { + public enum Type { + INPUT, + OUTPUT + } + + public enum DataType { + NUMBER, + STRING, + WIDGET + } + public static final int COLOR_NUMBER_DEFAULT = 0xFFFFFFFF; public static final int COLOR_STRING_DEFAULT = 0xFFFFC830; public static final int COLOR_WIDGET_DEFAULT = 0xFF40D0FF; private String name; - /** Stable persistence identity. Labels may change without breaking saved connections. */ private String stableKey; private final Type type; private final DataType dataType; private final int color; private boolean connected; - private double value; - private String stringValue = ""; - private Object widgetValue; - /** - * Creates a NUMBER pin (back-compat constructor used by existing nodes). - */ public WPin(String name, Type type, int color) { - this(name, type, DataType.NUMBER, color); + this(null, name, type, DataType.NUMBER, color); } public WPin(String name, Type type, DataType dataType, int color) { @@ -43,68 +35,70 @@ public WPin(String name, Type type, DataType dataType, int color) { } public WPin(String stableKey, String name, Type type, DataType dataType, int color) { - this.stableKey = stableKey == null || stableKey.isBlank() ? null : stableKey; - this.name = name; + this.stableKey = normalize(stableKey); + this.name = name == null ? "" : name; this.type = type; this.dataType = dataType; this.color = color; } - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - // Pin rendering is owned by WNode for layout alignment. + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name == null ? "" : name; + } + + public String getStableKey() { + return stableKey; } - public String getName() { return name; } - public void setName(String name) { this.name = name != null ? name : ""; } - public String getStableKey() { return stableKey; } public void setStableKey(String stableKey) { - this.stableKey = stableKey == null || stableKey.isBlank() ? null : stableKey; + this.stableKey = normalize(stableKey); + } + + public Type getType() { + return type; + } + + public DataType getDataType() { + return dataType; } - public Type getType() { return type; } - public DataType getDataType() { return dataType; } - public int getColor() { return color; } - public boolean isConnected() { return connected; } - public void setConnected(boolean connected) { this.connected = connected; } - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = new net.minecraft.nbt.CompoundTag(); - if (stableKey != null) tag.putString("portKey", stableKey); + public int getColor() { + return color; + } + + public boolean isConnected() { + return connected; + } + + public void setConnected(boolean connected) { + this.connected = connected; + } + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + if (stableKey != null) { + tag.putString("portKey", stableKey); + } tag.putString("name", name); - tag.putString("dataType", dataType.name().toLowerCase(java.util.Locale.ROOT)); + tag.putString("dataType", dataType.name().toLowerCase(Locale.ROOT)); tag.putInt("color", color); - switch (dataType) { - case NUMBER -> tag.putDouble("value", value); - case STRING -> tag.putString("s", stringValue == null ? "" : stringValue); - case WIDGET -> { - // Widget values are recomputed every tick from connected widget nodes — nothing to persist. - } - } return tag; } - public void load(net.minecraft.nbt.CompoundTag tag) { + public void load(CompoundTag tag) { if (tag.contains("portKey")) { setStableKey(tag.getString("portKey")); } if (tag.contains("name")) { setName(tag.getString("name")); } - switch (dataType) { - case NUMBER -> this.value = tag.getDouble("value"); - case STRING -> this.stringValue = tag.contains("s") ? tag.getString("s") : ""; - case WIDGET -> { - // see save() - } - } } - public double getValue() { return value; } - public void setValue(double value) { this.value = value; } - - public String getStringValue() { return stringValue == null ? "" : stringValue; } - public void setStringValue(String s) { this.stringValue = s == null ? "" : s; } - - public Object getWidgetValue() { return widgetValue; } - public void setWidgetValue(Object o) { this.widgetValue = o; } + private static String normalize(String value) { + return value == null || value.isBlank() ? null : value; + } } diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WButton.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WButton.java deleted file mode 100644 index bad355e..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WButton.java +++ /dev/null @@ -1,45 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; - -public class WButton extends WElement { - private String label; - private Runnable onClick; - - public WButton(String label, int width, Runnable onClick) { - this.label = label; - this.width = width; - this.height = 14; - this.onClick = onClick; - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - boolean hovered = mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - - ComputedEditorStyle.drawButton(graphics, x, y, width, height, hovered, false); - - // Label - int textW = net.minecraft.client.Minecraft.getInstance().font.width(label); - graphics.drawString( - net.minecraft.client.Minecraft.getInstance().font, - label, - x + (width - textW) / 2, - y + 3, - hovered ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.TEXT_PRIMARY, - false); - } - - @Override - public boolean handleMouseClick(double mouseX, double mouseY, int button) { - if (button == 0 && mouseX >= 0 && mouseX <= width && mouseY >= 0 && mouseY <= height) { - if (onClick != null) onClick.run(); - return true; - } - return false; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WCheckbox.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WCheckbox.java deleted file mode 100644 index c37a32e..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WCheckbox.java +++ /dev/null @@ -1,73 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.nbt.CompoundTag; - -public class WCheckbox extends WElement { - private boolean checked; - private String label; - private Runnable onToggle; - - public WCheckbox(String label) { - this.label = label; - this.width = 100; - this.height = 12; - } - - /** Called after the checked state changes (box click). */ - public void setOnToggle(Runnable onToggle) { - this.onToggle = onToggle; - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - boolean hovered = mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - - ComputedEditorStyle.drawField(graphics, x, y + 1, 10, 10, checked, hovered); - - if (checked) { - graphics.fill(x + 2, y + 3, x + 8, y + 9, ComputedEditorTheme.ACCENT); - } - - // Label - graphics.drawString( - net.minecraft.client.Minecraft.getInstance().font, - label, - x + 15, - y + 2, - ComputedEditorTheme.TEXT_PRIMARY, - false); - } - - @Override - public boolean handleMouseClick(double mouseX, double mouseY, int button) { - if (button == 0 && mouseX >= 0 && mouseX < getWidth() && mouseY >= 0 && mouseY < getHeight()) { - checked = !checked; - if (onToggle != null) { - onToggle.run(); - } - return true; - } - return false; - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putBoolean("Checked", checked); - return tag; - } - - @Override - public void load(CompoundTag tag) { - if (tag.contains("Checked")) { - checked = tag.getBoolean("Checked"); - } - } - - public boolean isChecked() { return checked; } - public void setChecked(boolean checked) { this.checked = checked; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WDropdown.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WDropdown.java deleted file mode 100644 index 29b54c0..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WDropdown.java +++ /dev/null @@ -1,123 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; - -/** - * Minimal dropdown / combobox. Closed: shows the currently selected option with a ▼ glyph; clicking - * toggles the open state. Open: renders option rows below the header; clicking a row selects it and - * closes. The element's height expands to cover the open list so click hit-testing still passes. - */ -public class WDropdown extends WElement { - private static final int HEADER_H = 14; - private static final int ROW_H = 12; - - private final List options; - private final Function labelFn; - private final Consumer onChange; - private T selected; - private boolean open; - - public WDropdown(int width, List options, Function labelFn, T initial, Consumer onChange) { - this.options = List.copyOf(options); - this.labelFn = labelFn; - this.onChange = onChange; - this.selected = initial; - this.onChange.accept(initial); - this.width = width; - this.height = HEADER_H; - } - - public T getSelected() { - return selected; - } - - public void setSelected(T value) { - this.selected = value; - if (onChange != null) { - onChange.accept(value); - } - } - - private void setOpen(boolean v) { - this.open = v; - this.height = v ? HEADER_H + options.size() * ROW_H : HEADER_H; - markLayoutDirty(); - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - var font = Minecraft.getInstance().font; - boolean headerHovered = mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + HEADER_H; - - ComputedEditorStyle.drawField(graphics, x, y, width, HEADER_H, open, headerHovered); - String label = selected == null ? "—" : labelFn.apply(selected); - graphics.drawString( - font, - label, - x + 4, - y + 3, - headerHovered ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.TEXT_PRIMARY, - false); - String glyph = open ? "▲" : "▼"; - int gw = font.width(glyph); - graphics.drawString(font, glyph, x + width - gw - 4, y + 3, ComputedEditorTheme.TEXT_SECONDARY, false); - - if (!open) { - return; - } - int listY = y + HEADER_H; - ComputedEditorStyle.drawMenuPanel(graphics, x, listY, width, options.size() * ROW_H); - for (int i = 0; i < options.size(); i++) { - int ry = listY + i * ROW_H; - boolean rowHovered = mouseX >= x && mouseX <= x + width && mouseY >= ry && mouseY <= ry + ROW_H; - T opt = options.get(i); - boolean isSelected = opt.equals(selected); - ComputedEditorStyle.drawMenuRow(graphics, x, ry, width, ROW_H, rowHovered, isSelected); - graphics.drawString(font, labelFn.apply(opt), x + 6, ry + 2, - rowHovered - ? ComputedEditorTheme.TEXT_HEADER - : (isSelected ? ComputedEditorTheme.ACCENT : ComputedEditorTheme.TEXT_PRIMARY), - false); - } - } - - @Override - public boolean handleMouseClick(double localX, double localY, int button) { - if (button != 0 || localX < 0 || localX > width) { - if (open) { - setOpen(false); - return true; - } - return false; - } - if (localY >= 0 && localY <= HEADER_H) { - playClick(); - setOpen(!open); - return true; - } - if (open) { - double listLocal = localY - HEADER_H; - int idx = (int) Math.floor(listLocal / ROW_H); - if (idx >= 0 && idx < options.size()) { - playClick(); - setSelected(options.get(idx)); - setOpen(false); - return true; - } - setOpen(false); - return true; - } - return false; - } - - private static void playClick() { - // No-op in shared code: keep dropdown server-loadable during graph deserialization. - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WFrequencySlotPair.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WFrequencySlotPair.java deleted file mode 100644 index 4a0089b..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WFrequencySlotPair.java +++ /dev/null @@ -1,143 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.item.ItemStack; - -/** - * Two item frequency slots (picture only), tinted red and blue, drawn side by side and centered in this - * element's width. - */ -public class WFrequencySlotPair extends WElement { - - private static final int SLOT = 22; - private static final int GAP = 8; - private static final int RED_BG = 0x99CC3030; - private static final int BLUE_BG = 0x993030CC; - private static final int RED_EDGE = 0xFFFF6666; - private static final int BLUE_EDGE = 0xFF8888FF; - - private ItemStack red = ItemStack.EMPTY; - private ItemStack blue = ItemStack.EMPTY; - - public WFrequencySlotPair() { - this.width = 160; - this.height = SLOT; - this.padding = 2; - this.margin = 2; - } - - public ItemStack getRed() { - return red; - } - - public ItemStack getBlue() { - return blue; - } - - private int slotsTotalWidth() { - return SLOT + GAP + SLOT; - } - - private int slotOriginX() { - return padding + Math.max(0, (width - slotsTotalWidth()) / 2); - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - int ox = x + slotOriginX(); - drawSlot(graphics, ox, y, red, true, mouseX, mouseY); - drawSlot(graphics, ox + SLOT + GAP, y, blue, false, mouseX, mouseY); - } - - private void drawSlot( - GuiGraphics graphics, - int ox, - int oy, - ItemStack stack, - boolean isRed, - int mouseX, - int mouseY) { - boolean hovered = mouseX >= ox && mouseX < ox + SLOT && mouseY >= oy && mouseY < oy + SLOT; - int fill = isRed ? RED_BG : BLUE_BG; - int edge = isRed ? RED_EDGE : BLUE_EDGE; - graphics.fill(ox, oy, ox + SLOT, oy + SLOT, hovered ? (fill | 0xFF000000) : fill); - graphics.renderOutline( - ox, - oy, - SLOT, - SLOT, - hovered ? ComputedEditorTheme.TEXT_HEADER : edge); - if (!stack.isEmpty()) { - graphics.renderItem(stack, ox + 2, oy + 2); - } - } - - @Override - public boolean handleMouseClick(double mouseX, double mouseY, int button) { - if (button != 0) { - return false; - } - int ox = slotOriginX(); - Minecraft mc = Minecraft.getInstance(); - if (mouseX >= ox && mouseX < ox + SLOT && mouseY >= 0 && mouseY < SLOT) { - playClick(mc); - dev.propulsionteam.computed.internal.node.client.ui.WNodeScreen.requestItemPick(this::setRed); - return true; - } - int bx = ox + SLOT + GAP; - if (mouseX >= bx && mouseX < bx + SLOT && mouseY >= 0 && mouseY < SLOT) { - playClick(mc); - dev.propulsionteam.computed.internal.node.client.ui.WNodeScreen.requestItemPick(this::setBlue); - return true; - } - return false; - } - - private static void playClick(Minecraft mc) { - // No-op in shared code: keep item slot pair server-loadable during graph deserialization. - } - - private void setRed(ItemStack st) { - red = st.isEmpty() ? ItemStack.EMPTY : st.copyWithCount(1); - } - - private void setBlue(ItemStack st) { - blue = st.isEmpty() ? ItemStack.EMPTY : st.copyWithCount(1); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - if (!red.isEmpty()) { - tag.putString("Red", BuiltInRegistries.ITEM.getKey(red.getItem()).toString()); - } - if (!blue.isEmpty()) { - tag.putString("Blue", BuiltInRegistries.ITEM.getKey(blue.getItem()).toString()); - } - return tag; - } - - @Override - public void load(CompoundTag tag) { - red = ItemStack.EMPTY; - blue = ItemStack.EMPTY; - if (tag.contains("Red")) { - ResourceLocation id = ResourceLocation.parse(tag.getString("Red")); - if (BuiltInRegistries.ITEM.containsKey(id)) { - red = new ItemStack(BuiltInRegistries.ITEM.get(id)); - } - } - if (tag.contains("Blue")) { - ResourceLocation id = ResourceLocation.parse(tag.getString("Blue")); - if (BuiltInRegistries.ITEM.containsKey(id)) { - blue = new ItemStack(BuiltInRegistries.ITEM.get(id)); - } - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WGif.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WGif.java deleted file mode 100644 index dcb0589..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WGif.java +++ /dev/null @@ -1,94 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.renderer.texture.DynamicTexture; -import net.minecraft.resources.ResourceLocation; -import com.mojang.blaze3d.platform.NativeImage; - -import javax.imageio.ImageIO; -import javax.imageio.ImageReader; -import javax.imageio.stream.ImageInputStream; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -public class WGif extends WElement { - private final List frames = new ArrayList<>(); - private final List delays = new ArrayList<>(); - private int totalFrames = 0; - private long startTime = -1; - private int totalDuration = 0; - - public WGif(ResourceLocation resource, int width, int height) { - this.width = width; - this.height = height; - loadGif(resource); - } - - private void loadGif(ResourceLocation resource) { - try { - var resourceOptional = Minecraft.getInstance().getResourceManager().getResource(resource); - if (resourceOptional.isEmpty()) return; - java.io.InputStream is = resourceOptional.get().open(); - ImageInputStream stream = ImageIO.createImageInputStream(is); - ImageReader reader = ImageIO.getImageReadersByFormatName("gif").next(); - reader.setInput(stream); - - int count = reader.getNumImages(true); - for (int i = 0; i < count; i++) { - BufferedImage bImg = reader.read(i); - NativeImage nImg = new NativeImage(bImg.getWidth(), bImg.getHeight(), false); - for (int y = 0; y < bImg.getHeight(); y++) { - for (int x = 0; x < bImg.getWidth(); x++) { - int argb = bImg.getRGB(x, y); - // Convert ARGB to ABGR for NativeImage - int a = (argb >> 24) & 0xFF; - int r = (argb >> 16) & 0xFF; - int g = (argb >> 8) & 0xFF; - int b = argb & 0xFF; - nImg.setPixelRGBA(x, y, (a << 24) | (b << 16) | (g << 8) | r); - } - } - - ResourceLocation loc = ResourceLocation.fromNamespaceAndPath("computed", "gif_frame_" + System.nanoTime() + "_" + i); - Minecraft.getInstance().getTextureManager().register(loc, new DynamicTexture(nImg)); - frames.add(loc); - - // Get delay (default to 100ms if unknown) - delays.add(100); - totalDuration += 100; - } - totalFrames = count; - reader.dispose(); - stream.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - if (frames.isEmpty()) return; - - if (startTime == -1) startTime = System.currentTimeMillis(); - long elapsed = (System.currentTimeMillis() - startTime) % Math.max(1, totalDuration); - - int currentFrame = 0; - long currentTotal = 0; - for (int i = 0; i < frames.size(); i++) { - currentTotal += delays.get(i); - if (elapsed < currentTotal) { - currentFrame = i; - break; - } - } - - ComputedEditorStyle.beginTextureIcon(graphics); - graphics.blit(frames.get(currentFrame), x, y, 0, 0, width, height, width, height); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WIconStrip.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WIconStrip.java deleted file mode 100644 index 0394a12..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WIconStrip.java +++ /dev/null @@ -1,87 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import java.util.List; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.resources.ResourceLocation; -import net.neoforged.fml.loading.FMLEnvironment; - -/** - * Horizontally tiles same-sized UI textures (typically 16×16) then optional text, for inline shortcut - * hints inside nodes. - */ -public class WIconStrip extends WElement { - private static final int TEX = 16; - - private final List icons; - private final String suffix; - private final int suffixColor; - private final int iconDraw; - private final int iconGap; - - public WIconStrip(List icons, String suffix, int suffixColor, int iconDraw) { - this(icons, suffix, suffixColor, iconDraw, 2); - } - - public WIconStrip(List icons, String suffix, int suffixColor, int iconDraw, int iconGap) { - this.icons = List.copyOf(icons); - this.suffix = suffix; - this.suffixColor = suffixColor; - this.iconDraw = iconDraw; - this.iconGap = iconGap; - int suffixWidth = measureTextWidth(suffix); - int lineHeight = measureLineHeight(); - int w = 0; - for (int i = 0; i < this.icons.size(); i++) { - w += iconDraw + (i < this.icons.size() - 1 ? iconGap : 0); - } - if (!suffix.isEmpty()) { - w += 3 + suffixWidth; - } - this.width = w; - this.height = Math.max(iconDraw, lineHeight); - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - var font = Minecraft.getInstance().font; - int cx = x; - ComputedEditorStyle.beginTextureIcon(graphics); - for (int i = 0; i < icons.size(); i++) { - ResourceLocation icon = icons.get(i); - graphics.pose().pushPose(); - graphics.pose().translate(cx, y, 0); - float s = iconDraw / (float) TEX; - graphics.pose().scale(s, s, 1.0f); - graphics.blit(icon, 0, 0, 0, 0, TEX, TEX, TEX, TEX); - graphics.pose().popPose(); - cx += iconDraw; - if (i < icons.size() - 1) { - cx += iconGap; - } - } - if (!suffix.isEmpty()) { - cx += 3; - int ty = y + (iconDraw - font.lineHeight) / 2 + 1; - graphics.drawString(font, suffix, cx, ty, suffixColor, false); - } - } - - private static int measureTextWidth(String text) { - if (FMLEnvironment.dist.isDedicatedServer()) { - return Math.max(8, text.length() * 6); - } - Minecraft mc = Minecraft.getInstance(); - return mc == null ? Math.max(8, text.length() * 6) : mc.font.width(text); - } - - private static int measureLineHeight() { - if (FMLEnvironment.dist.isDedicatedServer()) { - return 9; - } - Minecraft mc = Minecraft.getInstance(); - return mc == null ? 9 : mc.font.lineHeight; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WImage.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WImage.java deleted file mode 100644 index 47d6da9..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WImage.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.resources.ResourceLocation; - -public class WImage extends WElement { - private final ResourceLocation texture; - private final int u, v; - private final int texWidth, texHeight; - - public WImage(ResourceLocation texture, int width, int height) { - this(texture, 0, 0, width, height, width, height); - } - - public WImage(ResourceLocation texture, int u, int v, int width, int height, int texWidth, int texHeight) { - this.texture = texture; - this.u = u; - this.v = v; - this.width = width; - this.height = height; - this.texWidth = texWidth; - this.texHeight = texHeight; - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - ComputedEditorStyle.beginTextureIcon(graphics); - graphics.blit(texture, x, y, u, v, width, height, texWidth, texHeight); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WItemPickSlot.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WItemPickSlot.java deleted file mode 100644 index 2a6fce2..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WItemPickSlot.java +++ /dev/null @@ -1,85 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.item.ItemStack; - -/** - * Small row with label + clickable slot that opens the editor item picker (when a {@link dev.propulsionteam.computed.internal.node.client.ui.WNodeScreen} is active). - */ -public class WItemPickSlot extends WElement { - private final String label; - private ItemStack stack = ItemStack.EMPTY; - - public WItemPickSlot(String label) { - this.label = label; - this.width = 100; - this.height = 22; - } - - public ItemStack getStack() { - return stack; - } - - public void setStack(ItemStack stack) { - this.stack = stack.isEmpty() ? ItemStack.EMPTY : stack.copyWithCount(1); - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - Minecraft mc = Minecraft.getInstance(); - graphics.drawString(mc.font, label, x, y - 1, ComputedEditorTheme.TEXT_SECONDARY, false); - int sx = x; - int sy = y + mc.font.lineHeight - 2; - boolean hovered = mouseX >= sx && mouseX < sx + 18 && mouseY >= sy && mouseY < sy + 18; - ComputedEditorStyle.drawField(graphics, sx, sy, 18, 18, false, hovered); - if (!stack.isEmpty()) { - graphics.renderItem(stack, sx + 1, sy + 1); - } else { - graphics.drawString(mc.font, "∅", sx + 5, sy + 5, ComputedEditorTheme.TEXT_DISABLED, false); - } - } - - @Override - public boolean handleMouseClick(double mouseX, double mouseY, int button) { - if (button != 0) { - return false; - } - Minecraft mc = Minecraft.getInstance(); - int slotY = mc.font.lineHeight - 2; - if (mouseX >= 0 && mouseX < 18 && mouseY >= slotY && mouseY < slotY + 18) { - dev.propulsionteam.computed.internal.node.client.ui.WNodeScreen.requestItemPick(this::setStack); - return true; - } - return false; - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - if (!stack.isEmpty()) { - tag.putString("Item", BuiltInRegistries.ITEM.getKey(stack.getItem()).toString()); - } - return tag; - } - - @Override - public void load(CompoundTag tag) { - if (tag.contains("Item")) { - ResourceLocation id = ResourceLocation.parse(tag.getString("Item")); - if (BuiltInRegistries.ITEM.containsKey(id)) { - stack = new ItemStack(BuiltInRegistries.ITEM.get(id)); - } else { - stack = ItemStack.EMPTY; - } - } else { - stack = ItemStack.EMPTY; - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WLabel.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WLabel.java deleted file mode 100644 index 35e5ea7..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WLabel.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.Minecraft; -import net.neoforged.fml.loading.FMLEnvironment; - -public class WLabel extends WElement { - private String text; - private int color; - - public WLabel(String text) { - this(text, ComputedEditorTheme.TEXT_PRIMARY); - } - - public WLabel(String text, int color) { - this.text = text; - this.color = color; - this.width = measureTextWidth(text); - this.height = 10; - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - graphics.drawString(Minecraft.getInstance().font, text, x, y, color); - } - - public void setText(String text) { - this.text = text; - this.width = measureTextWidth(text); - } - - private static int measureTextWidth(String text) { - if (FMLEnvironment.dist.isDedicatedServer()) { - return Math.max(8, text.length() * 6); - } - Minecraft mc = Minecraft.getInstance(); - return mc == null ? Math.max(8, text.length() * 6) : mc.font.width(text); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WObjModel.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WObjModel.java deleted file mode 100644 index a2ec6a6..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WObjModel.java +++ /dev/null @@ -1,132 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import com.mojang.blaze3d.vertex.PoseStack; -import com.mojang.blaze3d.vertex.VertexConsumer; -import net.minecraft.client.renderer.MultiBufferSource; -import net.minecraft.client.renderer.RenderType; -import org.joml.Vector2f; -import org.joml.Vector3f; - -import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.List; - -public class WObjModel { - private final List vertices = new ArrayList<>(); - private final List uvs = new ArrayList<>(); - private final List faces = new ArrayList<>(); - - private Vector3f[] vertexNormals; - - public static WObjModel load(InputStream is) { - WObjModel model = new WObjModel(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) { - String line; - while ((line = reader.readLine()) != null) { - line = line.trim(); - if (line.isEmpty() || line.startsWith("#")) continue; - - String[] parts = line.split("\\s+"); - switch (parts[0]) { - case "v" -> model.vertices.add(new Vector3f( - Float.parseFloat(parts[1]), - Float.parseFloat(parts[2]), - Float.parseFloat(parts[3]) - )); - case "vt" -> model.uvs.add(new Vector2f( - Float.parseFloat(parts[1]), - 1.0f - Float.parseFloat(parts[2]) // Flip Y - )); - case "f" -> { - List vertexIndices = new ArrayList<>(); - for (int i = 1; i < parts.length; i++) { - String[] subParts = parts[i].split("/"); - int vIdx = Integer.parseInt(subParts[0]) - 1; - int uvIdx = subParts.length > 1 && !subParts[1].isEmpty() ? Integer.parseInt(subParts[1]) - 1 : -1; - vertexIndices.add(new VertexIndices(vIdx, uvIdx)); - } - for (int i = 1; i < vertexIndices.size() - 1; i++) { - model.faces.add(new Face(vertexIndices.get(0), vertexIndices.get(i), vertexIndices.get(i + 1))); - } - } - } - } - model.calculateSmoothNormals(); - } catch (Exception e) { - e.printStackTrace(); - } - return model; - } - - private net.minecraft.resources.ResourceLocation texture; - - public void setTexture(net.minecraft.resources.ResourceLocation texture) { - this.texture = texture; - } - - public static net.minecraft.resources.ResourceLocation loadExternalTexture(String path) { - try (InputStream is = new java.io.FileInputStream(path)) { - com.mojang.blaze3d.platform.NativeImage image = com.mojang.blaze3d.platform.NativeImage.read(is); - net.minecraft.client.renderer.texture.DynamicTexture dynamicTexture = new net.minecraft.client.renderer.texture.DynamicTexture(image); - return net.minecraft.client.Minecraft.getInstance().getTextureManager().register("webs_custom_" + java.util.UUID.randomUUID(), dynamicTexture); - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } - - private void calculateSmoothNormals() { - vertexNormals = new Vector3f[vertices.size()]; - for (int i = 0; i < vertexNormals.length; i++) vertexNormals[i] = new Vector3f(0, 0, 0); - - for (Face face : faces) { - Vector3f v1 = vertices.get(face.v1.vIdx); - Vector3f v2 = vertices.get(face.v2.vIdx); - Vector3f v3 = vertices.get(face.v3.vIdx); - - Vector3f edge1 = new Vector3f(v2).sub(v1); - Vector3f edge2 = new Vector3f(v3).sub(v1); - Vector3f normal = new Vector3f(edge1).cross(edge2).normalize(); - - vertexNormals[face.v1.vIdx].add(normal); - vertexNormals[face.v2.vIdx].add(normal); - vertexNormals[face.v3.vIdx].add(normal); - } - - for (Vector3f n : vertexNormals) n.normalize(); - } - - public void render(PoseStack poseStack, MultiBufferSource bufferSource, int packedLight, int packedOverlay, int color) { - RenderType type = texture != null ? RenderType.entityCutout(texture) : RenderType.solid(); - VertexConsumer builder = bufferSource.getBuffer(type); - float r = ((color >> 16) & 0xFF) / 255.0f; - float g = ((color >> 8) & 0xFF) / 255.0f; - float b = (color & 0xFF) / 255.0f; - float a = ((color >> 24) & 0xFF) / 255.0f; - if (a == 0) a = 1.0f; - - for (Face face : faces) { - renderVertex(face.v1, poseStack, builder, r, g, b, a, packedLight, packedOverlay); - renderVertex(face.v2, poseStack, builder, r, g, b, a, packedLight, packedOverlay); - renderVertex(face.v3, poseStack, builder, r, g, b, a, packedLight, packedOverlay); - } - } - - private void renderVertex(VertexIndices idx, PoseStack poseStack, VertexConsumer builder, float r, float g, float b, float a, int light, int overlay) { - Vector3f pos = vertices.get(idx.vIdx); - Vector3f normal = vertexNormals[idx.vIdx]; - Vector2f uv = idx.uvIdx != -1 ? uvs.get(idx.uvIdx) : new Vector2f(0, 0); - - builder.addVertex(poseStack.last().pose(), pos.x(), pos.y(), pos.z()) - .setColor(r, g, b, a) - .setUv(uv.x(), uv.y()) - .setOverlay(overlay) - .setLight(light) - .setNormal(poseStack.last(), normal.x(), normal.y(), normal.z()); - } - - private record VertexIndices(int vIdx, int uvIdx) {} - private record Face(VertexIndices v1, VertexIndices v2, VertexIndices v3) {} -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WSlider.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WSlider.java deleted file mode 100644 index b84fb21..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WSlider.java +++ /dev/null @@ -1,234 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.util.Mth; -import org.lwjgl.glfw.GLFW; - -public class WSlider extends WElement { - private static final int INPUT_W = 38; - private static final int GAP = 4; - - private double value; - private double min, max; - private String label; - private boolean dragging; - private double dragStartValue; - - private final int barWidth; - private boolean inputFocused; - private String inputBuffer = ""; - - public WSlider(String label, double min, double max, int width) { - this.label = label; - this.min = min; - this.max = max; - this.barWidth = width; - this.width = width + GAP + INPUT_W; - this.height = 14; - this.value = min; - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - boolean barHovered = mouseX >= x && mouseX <= x + barWidth && mouseY >= y && mouseY <= y + height; - - if (dragging) { - double relX = (mouseX - x) / (double) barWidth; - value = min + Mth.clamp(relX, 0, 1) * (max - min); - if (!inputFocused) { - inputBuffer = formatValue(value); - } - } - - // Background - graphics.fill(x, y + 4, x + barWidth, y + 10, ComputedEditorTheme.BACKGROUND_INPUT); - graphics.renderOutline(x, y + 4, barWidth, 6, ComputedEditorTheme.BORDER_DEFAULT); - - // Fill - int fillW = (int) ((value - min) / (max - min) * barWidth); - graphics.fill(x, y + 4, x + fillW, y + 10, ComputedEditorTheme.ACCENT_HEADER); - - // Knob - graphics.fill( - x + fillW - 2, - y + 2, - x + fillW + 2, - y + 12, - barHovered || dragging ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.ACCENT); - - // Label (above) - Minecraft mc = Minecraft.getInstance(); - String text = String.format("%s: %s", label, formatValue(value)); - graphics.drawString(mc.font, text, x, y - 8, ComputedEditorTheme.TEXT_SECONDARY, false); - - // Input box (right) - int ix = x + barWidth + GAP; - int iy = y + 2; - int ih = 10; - ComputedEditorStyle.drawField(graphics, ix, iy, INPUT_W, ih, inputFocused, false); - String shown = inputFocused ? inputBuffer : formatValue(value); - int textW = mc.font.width(shown); - int tx = ix + Math.max(2, INPUT_W - 3 - textW); - int ty = iy + 1; - graphics.drawString( - mc.font, - shown, - tx, - ty, - inputFocused ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.TEXT_PRIMARY, - false); - if (inputFocused && (System.currentTimeMillis() / 500) % 2 == 0) { - int cx = tx + textW; - graphics.fill(cx, ty - 1, cx + 1, ty + mc.font.lineHeight, ComputedEditorTheme.TEXT_HEADER); - } - } - - @Override - public boolean handleMouseClick(double mouseX, double mouseY, int button) { - if (button != 0) { - return false; - } - boolean onBar = mouseX >= 0 && mouseX <= barWidth && mouseY >= 0 && mouseY <= height; - int ix = barWidth + GAP; - boolean onInput = mouseX >= ix && mouseX <= ix + INPUT_W && mouseY >= 0 && mouseY <= height; - - if (onBar) { - commitInputIfFocused(); - inputFocused = false; - dragging = true; - dragStartValue = value; - playClick(1.03f); - return true; - } - if (onInput) { - if (!inputFocused) { - inputBuffer = formatValue(value); - } - inputFocused = true; - playClick(1.0f); - return true; - } - if (inputFocused) { - commitInputIfFocused(); - inputFocused = false; - } - return false; - } - - @Override - public boolean handleMouseRelease(double mouseX, double mouseY, int button) { - if (dragging && Math.abs(value - dragStartValue) > 1.0e-6) { - playClick(0.95f); - } - dragging = false; - return false; - } - - @Override - public boolean handleKeyPress(int keyCode, int scanCode, int modifiers) { - if (!inputFocused) { - return false; - } - if (keyCode == GLFW.GLFW_KEY_BACKSPACE) { - if (!inputBuffer.isEmpty()) { - inputBuffer = inputBuffer.substring(0, inputBuffer.length() - 1); - } - return true; - } - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - commitInputIfFocused(); - inputFocused = false; - return true; - } - if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - inputBuffer = formatValue(value); - inputFocused = false; - return true; - } - return false; - } - - @Override - public boolean handleCharTyped(char codePoint, int modifiers) { - if (!inputFocused) { - return false; - } - if ((codePoint >= '0' && codePoint <= '9') || codePoint == '.' || codePoint == '-') { - inputBuffer = inputBuffer + codePoint; - return true; - } - return false; - } - - @Override - public boolean isFocused() { - return inputFocused; - } - - @Override - public void clearFocus() { - commitInputIfFocused(); - inputFocused = false; - dragging = false; - } - - private void commitInputIfFocused() { - if (!inputFocused) { - return; - } - String s = inputBuffer.trim().replace(',', '.'); - if (s.isEmpty() || s.equals("-") || s.equals(".") || s.equals("-.")) { - inputBuffer = formatValue(value); - return; - } - try { - double parsed = Double.parseDouble(s); - value = Mth.clamp(parsed, min, max); - } catch (NumberFormatException ignored) { - // keep current value - } - inputBuffer = formatValue(value); - } - - private static String formatValue(double v) { - if (Math.abs(v - Math.rint(v)) < 1.0e-6) { - return String.valueOf((long) Math.rint(v)); - } - return String.format("%.2f", v); - } - - public double getValue() { - return value; - } - - public void setValue(double value) { - this.value = Mth.clamp(value, min, max); - if (!inputFocused) { - inputBuffer = formatValue(this.value); - } - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putDouble("value", value); - return tag; - } - - @Override - public void load(CompoundTag tag) { - if (tag.contains("value")) { - value = Mth.clamp(tag.getDouble("value"), min, max); - inputBuffer = formatValue(value); - } - } - - private static void playClick(float pitch) { - // No-op in shared code: keep slider server-loadable during graph deserialization. - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WTextField.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WTextField.java deleted file mode 100644 index 4d332f1..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WTextField.java +++ /dev/null @@ -1,289 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; -import net.neoforged.fml.loading.FMLEnvironment; -import org.lwjgl.glfw.GLFW; - -public class WTextField extends WElement { - private String value = ""; - private boolean focused = false; - private int cursorPos = 0; - private int selectionPos = 0; - private final int minWidth; - /** Last {@code value} string we measured against the font. Reference compare keeps {@link #render} hot-path allocation-free. */ - private String measuredValue = null; - - public WTextField(int width) { - this.minWidth = width; - this.width = width; - this.height = 12; - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - boolean hovered = mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - ComputedEditorStyle.drawField(graphics, x, y, width, height, focused, hovered); - - Minecraft mc = Minecraft.getInstance(); - int tx = x + 2; - int ty = y + 2; - - int selStart = Math.min(cursorPos, selectionPos); - int selEnd = Math.max(cursorPos, selectionPos); - if (selStart != selEnd) { - int left = tx + mc.font.width(value.substring(0, selStart)); - int right = tx + mc.font.width(value.substring(0, selEnd)); - graphics.fill(left, ty, right, ty + mc.font.lineHeight, ComputedEditorTheme.SELECTION_TEXT_BACKGROUND); - } - - graphics.drawString(mc.font, value, tx, ty, ComputedEditorTheme.TEXT_PRIMARY, false); - if (focused && (System.currentTimeMillis() / 500) % 2 == 0) { - int cx = tx + mc.font.width(value.substring(0, cursorPos)); - graphics.fill(cx, ty - 1, cx + 1, ty + mc.font.lineHeight + 1, ComputedEditorTheme.TEXT_HEADER); - } - } - - @Override - public boolean handleMouseClick(double localX, double localY, int button) { - focused = localX >= 0 && localX <= width && localY >= 0 && localY <= height; - if (focused && button == 0) { - int px = Math.max(0, (int) localX - 2); - cursorPos = indexForPixel(px); - selectionPos = cursorPos; - } - return focused; - } - - @Override - public boolean handleKeyPress(int keyCode, int scanCode, int modifiers) { - if (!focused) return false; - boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; - boolean shift = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; - - if (keyCode == GLFW.GLFW_KEY_LEFT_SHIFT - || keyCode == GLFW.GLFW_KEY_RIGHT_SHIFT - || keyCode == GLFW.GLFW_KEY_LEFT_CONTROL - || keyCode == GLFW.GLFW_KEY_RIGHT_CONTROL - || keyCode == GLFW.GLFW_KEY_LEFT_ALT - || keyCode == GLFW.GLFW_KEY_RIGHT_ALT - || keyCode == GLFW.GLFW_KEY_LEFT_SUPER - || keyCode == GLFW.GLFW_KEY_RIGHT_SUPER) { - return true; - } - - if (ctrl && keyCode == GLFW.GLFW_KEY_A) { - selectionPos = 0; - cursorPos = value.length(); - playTypingSound(0.97f); - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_C) { - copySelection(); - playTypingSound(1.02f); - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_X) { - if (hasSelection()) { - copySelection(); - deleteSelection(); - playTypingSound(0.9f); - } - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_V) { - String clip = Minecraft.getInstance().keyboardHandler.getClipboard(); - if (clip != null && !clip.isEmpty()) { - replaceSelection(clip); - playTypingSound(1.04f); - } - return true; - } - - if (keyCode == GLFW.GLFW_KEY_BACKSPACE) { - if (hasSelection()) { - deleteSelection(); - } else if (cursorPos > 0) { - int start = ctrl ? previousWordBoundary(cursorPos) : cursorPos - 1; - value = value.substring(0, start) + value.substring(cursorPos); - cursorPos = start; - selectionPos = cursorPos; - updateWidthForValue(); - } - playTypingSound(0.9f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_DELETE) { - if (hasSelection()) { - deleteSelection(); - } else if (cursorPos < value.length()) { - int end = ctrl ? nextWordBoundary(cursorPos) : cursorPos + 1; - value = value.substring(0, cursorPos) + value.substring(end); - updateWidthForValue(); - } - playTypingSound(0.9f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_LEFT) { - int next = ctrl ? previousWordBoundary(cursorPos) : Math.max(0, cursorPos - 1); - moveCursor(next, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_RIGHT) { - int next = ctrl ? nextWordBoundary(cursorPos) : Math.min(value.length(), cursorPos + 1); - moveCursor(next, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_HOME) { - moveCursor(0, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_END) { - moveCursor(value.length(), shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER || keyCode == GLFW.GLFW_KEY_ESCAPE) { - focused = false; - return true; - } - return false; - } - - @Override - public boolean handleCharTyped(char codePoint, int modifiers) { - if (!focused) return false; - if (Character.isISOControl(codePoint)) { - return true; - } - replaceSelection(String.valueOf(codePoint)); - playTypingSound(1.0f); - return true; - } - - public String getValue() { - return value; - } - - public void setValue(String value) { - this.value = value == null ? "" : value; - cursorPos = this.value.length(); - selectionPos = cursorPos; - updateWidthForValue(); - } - - @Override - public int getWidth() { - return super.getWidth(); - } - @Override - public net.minecraft.nbt.CompoundTag save() { - net.minecraft.nbt.CompoundTag tag = super.save(); - tag.putString("value", value); - return tag; - } - - @Override - public void load(net.minecraft.nbt.CompoundTag tag) { - this.value = tag.getString("value"); - cursorPos = this.value.length(); - selectionPos = cursorPos; - updateWidthForValue(); - } - - @Override - public boolean isFocused() { - return focused; - } - - @Override - public void clearFocus() { - focused = false; - } - - private boolean hasSelection() { - return cursorPos != selectionPos; - } - - private void moveCursor(int nextPos, boolean keepSelection) { - cursorPos = Math.max(0, Math.min(value.length(), nextPos)); - if (!keepSelection) { - selectionPos = cursorPos; - } - } - - private int indexForPixel(int pixelX) { - Minecraft mc = Minecraft.getInstance(); - for (int i = 0; i <= value.length(); i++) { - if (mc.font.width(value.substring(0, i)) >= pixelX) { - return i; - } - } - return value.length(); - } - - private int previousWordBoundary(int from) { - int i = Math.max(0, from); - while (i > 0 && Character.isWhitespace(value.charAt(i - 1))) i--; - while (i > 0 && !Character.isWhitespace(value.charAt(i - 1))) i--; - return i; - } - - private int nextWordBoundary(int from) { - int i = Math.min(value.length(), from); - while (i < value.length() && Character.isWhitespace(value.charAt(i))) i++; - while (i < value.length() && !Character.isWhitespace(value.charAt(i))) i++; - return i; - } - - private void deleteSelection() { - int start = Math.min(cursorPos, selectionPos); - int end = Math.max(cursorPos, selectionPos); - value = value.substring(0, start) + value.substring(end); - cursorPos = start; - selectionPos = start; - updateWidthForValue(); - } - - private void replaceSelection(String text) { - deleteSelection(); - value = value.substring(0, cursorPos) + text + value.substring(cursorPos); - cursorPos += text.length(); - selectionPos = cursorPos; - updateWidthForValue(); - } - - private void updateWidthForValue() { - if (value == measuredValue || (measuredValue != null && measuredValue.equals(value))) { - return; - } - measuredValue = value; - int textWidth; - if (FMLEnvironment.dist.isDedicatedServer()) { - textWidth = value.length() * 6; - } else { - Minecraft mc = Minecraft.getInstance(); - textWidth = mc == null ? value.length() * 6 : mc.font.width(value); - } - int newWidth = Math.max(minWidth, textWidth + 8); - if (newWidth != width) { - width = newWidth; - markLayoutDirty(); - } - } - - private void copySelection() { - if (!hasSelection()) { - return; - } - int start = Math.min(cursorPos, selectionPos); - int end = Math.max(cursorPos, selectionPos); - Minecraft.getInstance().keyboardHandler.setClipboard(value.substring(start, end)); - } - - private static void playTypingSound(float pitch) { - // No-op in shared code: keep text field server-loadable during graph deserialization. - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WViewport3D.java b/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WViewport3D.java deleted file mode 100644 index ea1713a..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/api/elements/WViewport3D.java +++ /dev/null @@ -1,125 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api.elements; - -import com.mojang.blaze3d.platform.Lighting; -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.renderer.texture.OverlayTexture; -import net.minecraft.world.item.ItemDisplayContext; -import net.minecraft.world.item.ItemStack; -import org.joml.Quaternionf; -import org.joml.Vector3f; - -import java.util.ArrayList; -import java.util.List; - -public class WViewport3D extends WElement { - private final List models = new ArrayList<>(); - private float zoom = 1.0f; - - public WViewport3D(int width, int height) { - this.width = width; - this.height = height; - } - - public void addModel(ItemStack stack, Vector3f pos, Vector3f rot, float scale) { - models.add(new ModelEntry(stack, null, pos, rot, scale)); - } - - public void addObjModel(WObjModel model, Vector3f pos, Vector3f rot, float scale) { - models.add(new ModelEntry(null, model, pos, rot, scale)); - } - - public void clear() { - models.clear(); - } - - @Override - public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float partialTick) { - // Background/Frame - graphics.fill(x, y, x + width, y + height, ComputedEditorTheme.BACKGROUND_INPUT); - graphics.renderOutline(x, y, width, height, ComputedEditorTheme.BORDER_DEFAULT); - - // Scissor to prevent bleeding out of the viewport - double guiScale = Minecraft.getInstance().getWindow().getGuiScale(); - int sx = (int) (graphics.pose().last().pose().get(3, 0) * guiScale); - int sy = (int) (graphics.pose().last().pose().get(3, 1) * guiScale); - int sw = (int) (width * graphics.pose().last().pose().get(0, 0) * guiScale); - int sh = (int) (height * graphics.pose().last().pose().get(1, 1) * guiScale); - graphics.enableScissor(sx, sy, sx + sw, sy + sh); - - // Enable 3D Depth testing and Culling - com.mojang.blaze3d.systems.RenderSystem.enableDepthTest(); - com.mojang.blaze3d.systems.RenderSystem.enableCull(); - graphics.pose().pushPose(); - // Move to element center and apply depth that fits in GUI - graphics.pose().translate(x + width / 2f, y + height / 2f, 200); - - // Use uniform scaling to avoid squashing - float scale = Math.min(width, height) / 2f * zoom; - graphics.pose().scale(scale, -scale, scale); - - // Setup Lighting for 3D objects - Lighting.setupFor3DItems(); - - for (ModelEntry entry : models) { - graphics.pose().pushPose(); - graphics.pose().translate(entry.pos.x, entry.pos.y, entry.pos.z); - - // Rotation - graphics.pose().mulPose(new Quaternionf().rotationXYZ( - (float)Math.toRadians(entry.rot.x), - (float)Math.toRadians(entry.rot.y), - (float)Math.toRadians(entry.rot.z) - )); - - graphics.pose().scale(entry.scale, entry.scale, entry.scale); - - // Render Item/Block or Custom OBJ - if (entry.stack != null) { - Minecraft.getInstance().getItemRenderer().renderStatic( - entry.stack, - ItemDisplayContext.FIXED, - 0xF000F0, - OverlayTexture.NO_OVERLAY, - graphics.pose(), - graphics.bufferSource(), - Minecraft.getInstance().level, - 0 - ); - } else if (entry.objModel != null) { - entry.objModel.render(graphics.pose(), graphics.bufferSource(), 0xF000F0, OverlayTexture.NO_OVERLAY, 0xFFFFFFFF); - } - - graphics.pose().popPose(); - } - - graphics.pose().popPose(); - com.mojang.blaze3d.systems.RenderSystem.disableCull(); - com.mojang.blaze3d.systems.RenderSystem.disableDepthTest(); - graphics.disableScissor(); - - // Reset Lighting for flat GUI - Lighting.setupForFlatItems(); - } - - public void setZoom(float zoom) { this.zoom = zoom; } - public List getModels() { return models; } - - public static class ModelEntry { - public ItemStack stack; - public WObjModel objModel; - public Vector3f pos; - public Vector3f rot; - public float scale; - - public ModelEntry(ItemStack stack, WObjModel objModel, Vector3f pos, Vector3f rot, float scale) { - this.stack = stack; - this.objModel = objModel; - this.pos = pos; - this.rot = rot; - this.scale = scale; - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/ComputedEditorStyle.java b/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/ComputedEditorStyle.java index 611cca5..e41ee19 100644 --- a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/ComputedEditorStyle.java +++ b/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/ComputedEditorStyle.java @@ -54,6 +54,26 @@ public static void drawBeveledPanel( public static void drawField( GuiGraphics graphics, int x, int y, int width, int height, boolean focused, boolean hovered) { + drawField( + graphics, + x, + y, + width, + height, + focused, + hovered, + ComputedEditorTheme.ACCENT); + } + + public static void drawField( + GuiGraphics graphics, + int x, + int y, + int width, + int height, + boolean focused, + boolean hovered, + int accent) { graphics.fill( x, y, @@ -67,7 +87,7 @@ public static void drawField( width, height, focused - ? ComputedEditorTheme.ACCENT + ? accent : hovered ? ComputedEditorTheme.BORDER_HIGHLIGHT : ComputedEditorTheme.BORDER_DEFAULT); } diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/GraphDiagnosticsController.java b/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/GraphDiagnosticsController.java deleted file mode 100644 index e3d8cb9..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/GraphDiagnosticsController.java +++ /dev/null @@ -1,71 +0,0 @@ -package dev.propulsionteam.computed.internal.node.client.editor; - -import dev.propulsionteam.computed.client.editor.DiagnosticTarget; -import dev.propulsionteam.computed.client.editor.EditorDiagnostic; -import dev.propulsionteam.computed.client.editor.EditorDiagnosticStore; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import java.util.List; -import java.util.UUID; - -/** Aggregates graph diagnostics and transactional-save failures into editor-facing targets. */ -public final class GraphDiagnosticsController { - private EditorDiagnosticStore diagnostics = EditorDiagnosticStore.empty(); - private WGraph cachedGraph; - private int cachedSignature; - private String saveFailure = ""; - - public EditorDiagnosticStore diagnostics() { - return diagnostics; - } - - public void invalidate() { - cachedGraph = null; - } - - public void setSaveFailure(String message) { - String normalized = message == null ? "" : message.trim(); - if (!normalized.equals(saveFailure)) { - saveFailure = normalized; - invalidate(); - } - } - - public void clearSaveFailure() { - setSaveFailure(""); - } - - public EditorDiagnosticStore refresh(WGraph graph) { - List graphDiagnostics = graph.getDiagnostics(); - int signature = 31 * graphDiagnostics.hashCode() + saveFailure.hashCode(); - if (cachedGraph == graph && cachedSignature == signature) { - return diagnostics; - } - - EditorDiagnosticStore updated = EditorDiagnosticStore.empty(); - for (WGraph.GraphDiagnostic diagnostic : graphDiagnostics) { - EditorDiagnostic.Severity severity = diagnostic.severity() == WGraph.DiagnosticSeverity.ERROR - ? EditorDiagnostic.Severity.ERROR - : EditorDiagnostic.Severity.WARNING; - if (diagnostic.nodeIds().isEmpty()) { - updated = updated.with(new EditorDiagnostic( - DiagnosticTarget.editor(), severity, diagnostic.code(), diagnostic.message())); - } else { - for (UUID nodeId : diagnostic.nodeIds()) { - updated = updated.with(new EditorDiagnostic( - DiagnosticTarget.node(nodeId), severity, diagnostic.code(), diagnostic.message())); - } - } - } - if (!saveFailure.isEmpty()) { - updated = updated.with(new EditorDiagnostic( - DiagnosticTarget.editor(), - EditorDiagnostic.Severity.ERROR, - "computed.save.rejected", - saveFailure)); - } - diagnostics = updated; - cachedGraph = graph; - cachedSignature = signature; - return diagnostics; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/NodeDescriptionCatalog.java b/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/NodeDescriptionCatalog.java deleted file mode 100644 index 77c9773..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/NodeDescriptionCatalog.java +++ /dev/null @@ -1,115 +0,0 @@ -package dev.propulsionteam.computed.internal.node.client.editor; - -import java.util.Map; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -/** Computed-owned descriptions for the built-in node palette. Add-ons receive a useful fallback. */ -public final class NodeDescriptionCatalog { - private static final Map BUILT_INS = Map.ofEntries( - Map.entry("create_redstone_link_receiver", "Receives a value from a matching Create redstone link frequency."), - Map.entry("create_redstone_link_sender", "Sends a value over a matching Create redstone link frequency."), - Map.entry("block_location", "Provides the configured block position as X, Y, and Z values."), - Map.entry("block_presence", "Tests whether a block matching the configured target is present."), - Map.entry("block_rotation", "Reads the rotation or facing of the configured block."), - Map.entry("command", "Runs a Minecraft command when its execution input is triggered."), - Map.entry("comparator_read", "Reads the comparator output level from the configured block face."), - Map.entry("concatenate_strings", "Joins two text values into one string."), - Map.entry("if_branch", "Routes execution through the true or false branch of a condition."), - Map.entry("redstone_input", "Reads the redstone level from a configured adjacent block face."), - Map.entry("redstone_emitter", "Outputs a configurable redstone signal to an adjacent block face."), - Map.entry("switch", "Selects one of two values using a boolean condition."), - Map.entry("world_time", "Provides the current world day and time values."), - Map.entry("button_widget", "Creates an interactive button in the computer's widget output."), - Map.entry("clock_widget", "Displays the current time in the computer's widget output."), - Map.entry("color_source", "Produces a configurable RGB color value."), - Map.entry("progress_bar_widget", "Displays a value as a progress bar widget."), - Map.entry("slider_widget", "Creates an interactive numeric slider widget."), - Map.entry("text_source", "Produces a configurable text value."), - Map.entry("text_widget", "Displays text in the computer's widget output."), - Map.entry("peripheral", "Connects the graph to a supported hardware peripheral."), - Map.entry("counter", "Counts trigger events and exposes the current count."), - Map.entry("mux", "Selects one of several inputs using an index."), - Map.entry("pass_every_n", "Passes every Nth rising-edge trigger."), - Map.entry("bool_to_level", "Converts a boolean value to a redstone level."), - Map.entry("display", "Displays an input value directly on the node."), - Map.entry("level_to_bool", "Converts a redstone level to a boolean value."), - Map.entry("logic_and", "Returns true only when both inputs are true."), - Map.entry("edge_fall", "Emits a trigger when a boolean changes from true to false."), - Map.entry("edge_rise", "Emits a trigger when a boolean changes from false to true."), - Map.entry("logic_nand", "Returns false only when both inputs are true."), - Map.entry("logic_nor", "Returns true only when both inputs are false."), - Map.entry("logic_or", "Returns true when either input is true."), - Map.entry("schmitt", "Applies separate rising and falling thresholds to a numeric input."), - Map.entry("logic_xnor", "Returns true when both boolean inputs are equal."), - Map.entry("logic_xor", "Returns true when exactly one input is true."), - Map.entry("cmp_approx", "Tests whether two numbers are equal within a tolerance."), - Map.entry("cmp_eq", "Tests whether two values are equal."), - Map.entry("cmp_ge", "Tests whether the first number is greater than or equal to the second."), - Map.entry("cmp_gt", "Tests whether the first number is greater than the second."), - Map.entry("cmp_le", "Tests whether the first number is less than or equal to the second."), - Map.entry("cmp_lt", "Tests whether the first number is less than the second."), - Map.entry("d_flipflop", "Stores a boolean value on the rising edge of its clock input."), - Map.entry("sr_latch", "Stores a boolean state controlled by set and reset inputs."), - Map.entry("logic_not", "Inverts a boolean input."), - Map.entry("math_add", "Adds two numbers."), - Map.entry("math_clamp", "Limits a number to a configurable minimum and maximum."), - Map.entry("math_divide", "Divides the first number by the second."), - Map.entry("math_lerp", "Interpolates between two numbers by a configurable amount."), - Map.entry("math_map", "Remaps a number from one range into another."), - Map.entry("math_max", "Returns the larger of two numbers."), - Map.entry("math_min", "Returns the smaller of two numbers."), - Map.entry("math_mod", "Returns the remainder after division."), - Map.entry("math_multiply", "Multiplies two numbers."), - Map.entry("math_pow", "Raises the first number to the power of the second."), - Map.entry("math_subtract", "Subtracts the second number from the first."), - Map.entry("math_atan2", "Calculates the two-argument arctangent in radians."), - Map.entry("math_cos", "Calculates the cosine of an angle in radians."), - Map.entry("math_sin", "Calculates the sine of an angle in radians."), - Map.entry("math_tan", "Calculates the tangent of an angle in radians."), - Map.entry("math_abs", "Returns the absolute value of a number."), - Map.entry("math_average", "Calculates the average of its numeric inputs."), - Map.entry("math_ceil", "Rounds a number upward to the nearest integer."), - Map.entry("math_exp", "Raises Euler's number to the input power."), - Map.entry("math_floor", "Rounds a number downward to the nearest integer."), - Map.entry("math_log10", "Calculates the base-10 logarithm of a number."), - Map.entry("math_log", "Calculates the natural logarithm of a number."), - Map.entry("math_negate", "Changes the sign of a number."), - Map.entry("quantize_redstone", "Rounds and limits a number to a redstone level from 0 to 15."), - Map.entry("math_random", "Produces a random number within the configured range."), - Map.entry("math_round", "Rounds a number to the nearest integer."), - Map.entry("math_sign", "Returns -1, 0, or 1 for the sign of a number."), - Map.entry("math_sqrt", "Calculates the square root of a number."), - Map.entry("constant", "Produces a configurable numeric constant."), - Map.entry("delay", "Delays an incoming value or trigger by a configured duration."), - Map.entry("oscillator", "Produces a repeating waveform at a configurable rate."), - Map.entry("pulse", "Produces a timed pulse when triggered."), - Map.entry("sample_hold", "Captures and holds an input value when triggered."), - Map.entry("tick", "Emits an execution trigger every computer tick."), - Map.entry("rgb_preview", "Previews an RGB color inside the node editor."), - Map.entry("3d_preview", "Displays custom 3D viewport content inside the node."), - Map.entry("tool_section", "Creates a labeled section for organizing nodes."), - Map.entry("function_card", "Runs a reusable nested function graph."), - Map.entry("fn_start", "Defines inputs at the beginning of a nested function."), - Map.entry("fn_end", "Defines outputs at the end of a nested function.")); - - private NodeDescriptionCatalog() {} - - public static String description(ResourceLocation nodeType, Component title) { - String builtIn = BUILT_INS.get(nodeType.getPath()); - return builtIn != null ? builtIn : "Adds the " + title.getString() + " node."; - } - - public static Component component(ResourceLocation nodeType, Component title) { - String key = "node." + nodeType.getNamespace() + "." + nodeType.getPath() + ".description"; - return Component.translatableWithFallback(key, description(nodeType, title)); - } - - public static boolean hasBuiltInDescription(ResourceLocation nodeType) { - return BUILT_INS.containsKey(nodeType.getPath()); - } - - public static int builtInDescriptionCount() { - return BUILT_INS.size(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/NodeLodRenderer.java b/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/NodeLodRenderer.java deleted file mode 100644 index ace6c78..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/NodeLodRenderer.java +++ /dev/null @@ -1,178 +0,0 @@ -package dev.propulsionteam.computed.internal.node.client.editor; - -import dev.propulsionteam.computed.client.editor.EditorDetailLevel; -import dev.propulsionteam.computed.internal.node.api.WNode; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.Font; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.util.Mth; - -/** Draws component-free node bodies and crisp screen-space titles for compact editor levels. */ -public final class NodeLodRenderer { - public record VisualState( - boolean hovered, - boolean selected, - boolean diagnosticError, - boolean diagnosticWarning, - boolean peripheralLocked) {} - - private record TitleKey(String title, int maximumWidth) {} - - private record PendingLabel( - int x, int y, int width, int height, int drawOrder, String text, int color) {} - - private final List pendingLabels = new ArrayList<>(); - private final Map fittedTitleCache = new HashMap<>(); - - public void beginFrame() { - pendingLabels.clear(); - if (fittedTitleCache.size() > 4096) { - fittedTitleCache.clear(); - } - } - - public void renderNode( - GuiGraphics graphics, - Font font, - WNode node, - VisualState state, - EditorDetailLevel detailLevel, - float contentScale, - int screenLeft, - int screenTop, - int screenRight, - int screenBottom, - int drawOrder) { - node.ensureLayoutUpToDate(); - renderBody(graphics, node, state, detailLevel, contentScale); - queueLabel( - font, - node, - state, - screenLeft, - screenTop, - screenRight, - screenBottom, - drawOrder); - } - - public void renderLabels(GuiGraphics graphics) { - if (pendingLabels.isEmpty()) { - return; - } - pendingLabels.sort(java.util.Comparator.comparingInt(PendingLabel::drawOrder)); - for (PendingLabel pending : pendingLabels) { - graphics.fill( - pending.x() - 2, - pending.y() - 1, - pending.x() + pending.width() + 2, - pending.y() + pending.height() + 1, - 0xE01A1A1A); - graphics.drawString( - Minecraft.getInstance().font, - pending.text(), - pending.x(), - pending.y(), - pending.color(), - false); - } - } - - private static void renderBody( - GuiGraphics graphics, - WNode node, - VisualState state, - EditorDetailLevel detailLevel, - float contentScale) { - int x = node.getX(); - int y = node.getY(); - int width = Math.max(1, node.getWidth()); - int height = Math.max(1, node.getHeight()); - int fill = ComputedEditorTheme.nodeBody(state.hovered(), state.selected(), state.peripheralLocked()); - if (detailLevel == EditorDetailLevel.OVERVIEW && !state.hovered() && !state.selected()) { - fill = ComputedEditorTheme.BACKGROUND_SECTION; - } - graphics.fill(x, y, x + width, y + height, fill); - - int stroke = Mth.clamp( - Mth.ceil(1.0f / Math.max(0.1f, contentScale)), - 1, - Math.max(1, Math.min(width, height) / 2)); - int accent = ComputedEditorTheme.nodeOutline( - state.selected(), - false, - state.diagnosticError(), - state.diagnosticWarning(), - state.peripheralLocked()); - fillOutline(graphics, x, y, width, height, stroke, accent); - graphics.fill( - x + stroke, - y + stroke, - Math.max(x + stroke, x + width - stroke), - Math.min(y + height, y + stroke * 2), - accent); - - if (state.peripheralLocked() || state.diagnosticError() || state.diagnosticWarning()) { - int badge = Math.min(Math.max(stroke * 3, 2), Math.max(2, Math.min(width, height) / 3)); - graphics.fill(x + width - badge, y, x + width, y + badge, accent); - } - } - - private void queueLabel( - Font font, - WNode node, - VisualState state, - int screenLeft, - int screenTop, - int screenRight, - int screenBottom, - int drawOrder) { - int projectedWidth = Math.max(1, screenRight - screenLeft); - int projectedHeight = Math.max(1, screenBottom - screenTop); - int availableWidth = Math.max(projectedWidth - 6, 48); - availableWidth = Math.min(180, availableWidth); - String title = fitTitle(font, node.getTitle(), availableWidth); - if (title.isEmpty()) { - return; - } - int textWidth = font.width(title); - int x = screenLeft + (projectedWidth - textWidth) / 2; - int y = screenTop + (projectedHeight - font.lineHeight) / 2; - int color = ComputedEditorTheme.nodeLabel( - state.diagnosticError(), state.diagnosticWarning(), state.peripheralLocked()); - pendingLabels.add(new PendingLabel( - x, y, textWidth, font.lineHeight, drawOrder, title, color)); - } - - private String fitTitle(Font font, String title, int maximumWidth) { - String safeTitle = title == null ? "" : title; - TitleKey key = new TitleKey(safeTitle, maximumWidth); - return fittedTitleCache.computeIfAbsent(key, ignored -> { - if (font.width(safeTitle) <= maximumWidth) { - return safeTitle; - } - String ellipsis = "\u2026"; - int prefixWidth = maximumWidth - font.width(ellipsis); - return prefixWidth <= 0 ? "" : font.plainSubstrByWidth(safeTitle, prefixWidth) + ellipsis; - }); - } - - private static void fillOutline( - GuiGraphics graphics, int x, int y, int width, int height, int stroke, int color) { - int right = x + width; - int bottom = y + height; - graphics.fill(x, y, right, Math.min(bottom, y + stroke), color); - graphics.fill(x, Math.max(y, bottom - stroke), right, bottom, color); - graphics.fill(x, y + stroke, Math.min(right, x + stroke), Math.max(y + stroke, bottom - stroke), color); - graphics.fill( - Math.max(x, right - stroke), - y + stroke, - right, - Math.max(y + stroke, bottom - stroke), - color); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/WireEditorController.java b/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/WireEditorController.java index 3137301..3fc6a13 100644 --- a/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/WireEditorController.java +++ b/src/main/java/dev/propulsionteam/computed/internal/node/client/editor/WireEditorController.java @@ -179,8 +179,7 @@ public Hover updateHover( ensureSpatialIndex(graph, contentScale, editorRevision, geometryMoving, EditorDetailLevel.FULL); int queryRadius = Math.max(waypointPickRadius, curvePickRadius) + 3; - GraphRect pickArea = new GraphRect( - graphX - queryRadius, graphY - queryRadius, graphX + queryRadius, graphY + queryRadius); + GraphRect pickArea = GraphRect.around(new GraphPoint(graphX, graphY), queryRadius); List> nearbySegments = spatialIndex.query(pickArea); TreeSet nearbyConnections = new TreeSet<>(); diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/client/ui/WNodeScreen.java b/src/main/java/dev/propulsionteam/computed/internal/node/client/ui/WNodeScreen.java index 3b576c9..c0732ff 100644 --- a/src/main/java/dev/propulsionteam/computed/internal/node/client/ui/WNodeScreen.java +++ b/src/main/java/dev/propulsionteam/computed/internal/node/client/ui/WNodeScreen.java @@ -1,6553 +1,1041 @@ -/** https://github.com/webyep-art/webs_node_lib (MIT, webyep). */ package dev.propulsionteam.computed.internal.node.client.ui; -import dev.propulsionteam.computed.internal.node.api.FunctionCardNode; -import dev.propulsionteam.computed.internal.node.api.FunctionDefinitionStore; -import dev.propulsionteam.computed.internal.node.api.UiKeyTextures; -import dev.propulsionteam.computed.internal.node.api.FunctionEndNode; -import dev.propulsionteam.computed.internal.node.api.FunctionStartNode; -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.WConnection; -import dev.propulsionteam.computed.client.ComputedGraphShareCodec; -import dev.propulsionteam.computed.internal.node.ProgramBridge; -import dev.propulsionteam.computed.node.program.GraphModel; -import dev.propulsionteam.computed.node.program.ProgramCodec; -import dev.propulsionteam.computed.client.editor.DiagnosticTarget; import dev.propulsionteam.computed.client.editor.EditorCommand; -import dev.propulsionteam.computed.client.editor.EditorDiagnostic; -import dev.propulsionteam.computed.client.editor.EditorHistory; import dev.propulsionteam.computed.client.editor.EditorDetailLevel; +import dev.propulsionteam.computed.client.editor.EditorHistory; import dev.propulsionteam.computed.client.editor.GraphPoint; import dev.propulsionteam.computed.client.editor.GraphRect; -import dev.propulsionteam.computed.client.editor.UniformGridSpatialIndex; -import dev.propulsionteam.computed.internal.node.client.editor.GraphDiagnosticsController; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorStyle; +import dev.propulsionteam.computed.client.editor.canvas.InertialViewport; +import dev.propulsionteam.computed.internal.node.api.WConnection; +import dev.propulsionteam.computed.internal.node.api.WGraph; +import dev.propulsionteam.computed.internal.node.api.WNode; +import dev.propulsionteam.computed.internal.node.api.WPin; import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorTheme; -import dev.propulsionteam.computed.internal.node.client.editor.ComputedEditorIcons; -import dev.propulsionteam.computed.internal.node.client.editor.NodeDescriptionCatalog; -import dev.propulsionteam.computed.internal.node.client.editor.NodeLodRenderer; import dev.propulsionteam.computed.internal.node.client.editor.PointerGestureClassifier; import dev.propulsionteam.computed.internal.node.client.editor.WireEditorController; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.item.Item; -import net.minecraft.world.item.ItemStack; -import net.minecraft.sounds.SoundEvents; import net.minecraft.util.Mth; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.NbtAccounter; -import net.minecraft.nbt.NbtIo; -import net.minecraft.nbt.Tag; -import net.minecraft.nbt.TagParser; -import java.util.Base64; -import java.util.UUID; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Set; -import java.util.ArrayDeque; -import java.util.Comparator; -import java.util.Locale; -import java.util.function.Consumer; -import java.util.function.Predicate; +import net.minecraft.world.item.ItemStack; import org.lwjgl.glfw.GLFW; -/** - * The main GUI screen for editing node graphs. - * Supports zooming, panning, multiple node selection, and real-time data flow visualization. - */ public class WNodeScreen extends Screen { - /** - * Client viewport persistence key for the main (non-nested) graph. Inner function bodies use - * {@link java.util.UUID#toString()} of the {@link FunctionCardNode} id. - */ public static final String EDITOR_VIEWPORT_ROOT = "root"; - /** Inset from screen edges so the editor is not fullscreen; world stays visible around it. */ - private static final int VIEW_INSET_NORMAL = 40; - private static final float OPEN_DURATION_SEC = 0.55f; - /** Grid lines stay this many GUI pixels apart on screen regardless of zoom or window size. */ - private static final float GRID_SPACING_SCREEN_PX = 20f; - /** Target minimum stroke width on screen (px); grows in graph space when zoomed out so lines stay visible. */ - private static final float GRID_LINE_WIDTH_SCREEN_PX = 1.35f; - private static final float ZOOM_SCROLL_STEP = 0.05f; + private static final float ZOOM_STEP = 0.05f; + private static final int MAX_HISTORY = 80; + private static final int GRID_SPACING = 20; + private static final int ITEM_PICKER_WIDTH = 260; + private static final int ITEM_PICKER_ROWS = 9; + private static final int ITEM_PICKER_ROW_HEIGHT = 20; - /** Fills the window; inset becomes 0. Always on — windowed mode just hides content for no gain. */ - private final boolean editorFullscreen = true; - private static final int FULLSCREEN_BTN = 22; - private static final int FULLSCREEN_BTN_PAD = 6; - private static final int ICON_SIZE = 16; - private static final ResourceLocation ICON_DUPLICATE = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/duplicate.png"); - private static final ResourceLocation ICON_DISCONNECT = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/disconnect.png"); - private static final ResourceLocation ICON_MAXIMIZE = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/maximize.png"); - private static final ResourceLocation ICON_MINIMIZE = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/minimize.png"); - /** Functions / library picker (computer editor only when {@link #functionStore} is non-null). */ - private static final ResourceLocation ICON_SCHEMATIC = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/schematic.png"); - private static final ResourceLocation ICON_PLAY = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/play.png"); - private static final ResourceLocation ICON_PAUSE = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/pause.png"); - private static final ResourceLocation ICON_SAVE_DISK = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/save_multicolor.png"); - private static final ResourceLocation ICON_FOLDER = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/folder_multicolor.png"); - private static final ResourceLocation ICON_UPLOAD = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/upload_multicolor.png"); - private static final ResourceLocation ICON_SCROLLER_MULTICOLOR = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/scroller_multicolor.png"); - private static final ResourceLocation ICON_SCROLLER_DISABLED = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/scroller_disabled.png"); - private static final ResourceLocation ICON_UI_CLICK = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/click.png"); - private static final ResourceLocation ICON_UI_DOUBLE_CLICK = - ResourceLocation.fromNamespaceAndPath("computed", "textures/ui/icons/double_click.png"); - private static final ResourceLocation KEY_CAP_ALT = UiKeyTextures.key("alt"); - private static final ResourceLocation KEY_CAP_DEL = UiKeyTextures.key("del"); - private static final ResourceLocation KEY_CAP_X = UiKeyTextures.key("x"); - private static final ResourceLocation KEY_CAP_ESC = UiKeyTextures.key("esc"); - private static final ResourceLocation SECTION_TOOL_TYPE = - ResourceLocation.fromNamespaceAndPath("computed", "tool_section"); + private static WNodeScreen activeScreen; - /** Current editing graph (may be a nested {@link FunctionCardNode}'s inner graph). */ + private final WireEditorController wires = new WireEditorController(); + private final EditorHistory history = new EditorHistory<>(this, MAX_HISTORY); private WGraph graph; - - /** Null outside {@link dev.propulsionteam.computed.client.ComputerEditorScreen}. */ - protected final FunctionDefinitionStore functionStore; - - /** - * When non-null, {@link #isEditorPeripheralLocked} is true if this predicate holds for the node type id - * (Computed: peripheral item not in computer inventory). Null in standalone / demo editor. - */ - private final Predicate editorPeripheralLocked; - - private boolean functionPickerOpen; - - /** Nested function body: live simulation while editing (client preview). */ - private boolean nestedFunctionTestPlaying; - - /** Client {@code config/.../functions/*.nbt} list for the schematic dropdown import row. */ - /** First visible row in the definitions list (5 rows viewport). */ - private int functionLibraryListScroll; - /** First visible row in the import flyout (5 rows max). */ - private int functionDiscImportListScroll; - private boolean functionImportSubmenuOpen; - private final List functionDiscImportFiles = new ArrayList<>(); - - /** Function library row selection / rename (schematic dropdown). */ - private UUID selectedLibraryFunctionId = null; - private UUID renamingLibraryFunctionId = null; - private String libraryFnRenameBuffer = ""; - private int libraryFnRenameCursor = 0; - private int libraryFnRenameSelectionPos = 0; - private long lastLibraryFunctionClickAtMs = 0; - private UUID lastLibraryFunctionClickId = null; - - /** Naming overlay after "+ New function" (computer editor only). */ - private boolean newFunctionNamingOpen; - private String newFunctionNameBuffer = ""; - private boolean exportDialogOpen; - private String exportDialogText = ""; - private boolean importDialogOpen; - private String importDialogText = ""; - private String importDialogStatus = ""; - private boolean importDialogStatusError; - - private record FunctionEditFrame(WGraph parentGraph, FunctionCardNode openedHost) {} - - private final ArrayDeque functionEditStack = new ArrayDeque<>(); - - /** Transitional snapshot commands preserve the current editor's complete undo semantics. */ - private static final int MAX_UNDO = 80; - private final EditorHistory editorHistory; - private boolean historySuspended = false; - /** Monotonic client-side edit generation used by transactional autosave. */ - private long editorRevision; - - private static final double NODE_INDEX_CELL_SIZE = 192.0; - private static final double NODE_INDEX_PADDING = 10.0; - private final UniformGridSpatialIndex nodeSpatialIndex = - new UniformGridSpatialIndex<>(NODE_INDEX_CELL_SIZE); - private final Map indexedNodeOrder = new HashMap<>(); - private final Set spatiallyInitializedNodes = - java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>()); - private WGraph indexedGraph; - private long indexedEditorRevision = Long.MIN_VALUE; - - private final WireEditorController wireController = new WireEditorController(); - private final NodeLodRenderer nodeLodRenderer = new NodeLodRenderer(); - private EditorDetailLevel editorDetailLevel = EditorDetailLevel.FULL; - private boolean showLodInteractionHint; - - private final GraphDiagnosticsController diagnosticsController = new GraphDiagnosticsController(); - private boolean diagnosticsPanelOpen; - - // Viewport panning and zoom - private double panX = 0; - private double panY = 0; - private boolean cameraFocusActive = false; - private double cameraFocusStartPanX = 0; - private double cameraFocusStartPanY = 0; - private double cameraFocusTargetPanX = 0; - private double cameraFocusTargetPanY = 0; - private double cameraFocusElapsedSec = 0; - private double cameraFocusDurationSec = 0.35; - private boolean isPanning = false; - private float zoom = 1.0f; - - // Interaction state - private WNode selectedNode = null; - private WNode draggingNode = null; - private double dragOffsetX = 0; - private double dragOffsetY = 0; - private WGraph.WSection draggingSection = null; - private int sectionDragOffsetX = 0; - private int sectionDragOffsetY = 0; - /** Section position when header drag began; member nodes use original graph pos + (new - start). */ - private int sectionDragStartSectionX = 0; - private int sectionDragStartSectionY = 0; - /** Last total delta applied to waypoints during this section drag (see {@link #sectionDragPrevTotalDy}). */ - private int sectionDragPrevTotalDx = 0; - private int sectionDragPrevTotalDy = 0; - private final List sectionDragMemberNodes = new ArrayList<>(); - private final Map sectionDragOriginalNodePos = new HashMap<>(); - /** Nested sections fully inside the dragged band; move with the parent on drag. */ - private final List sectionDragChildSections = new ArrayList<>(); - private final Map sectionDragOriginalNestedSectionPos = new HashMap<>(); - - private enum SectionResizeHandle { - NONE, E, S, W, SE, SW - } - - private static final int MIN_SECTION_W = 28; - private static final int MIN_SECTION_H = 24; - private WGraph.WSection resizingSection = null; - private SectionResizeHandle sectionResizeHandle = SectionResizeHandle.NONE; - private int sectionResizeStartX; - private int sectionResizeStartY; - private int sectionResizeStartW; - private int sectionResizeStartH; - private int sectionResizeGrabNx; - private int sectionResizeGrabNy; - - // Connection state - private WNode linkingNode = null; + private final InertialViewport viewport = new InertialViewport(); + private long revision; + private String saveFailure = ""; + private WNode selectedNode; + private WNode draggingNode; + private double dragOffsetX; + private double dragOffsetY; + private WNode linkingNode; private int linkingPin = -1; - /** After dropping an output wire on empty space: connect this output to the next menu-spawned node's first input. */ - private WNode pendingWireFromNode = null; - private int pendingWireFromOutputPin = -1; - /** While the add-node menu is open from a wire drop, freeze the preview end at the drop point. */ - private boolean pendingWireDragFrozen = false; - private int pendingWireFrozenTx; - private int pendingWireFrozenTy; - private int mouseX, mouseY; - - private int draggingWireConnIdx = -1; - private int draggingWireWaypointIdx = -1; - - // Selection state - private boolean isSelecting = false; - private double selStartX, selStartY, selEndX, selEndY; - private boolean isCreatingSection = false; - private int sectionCreateStartX, sectionCreateStartY, sectionCreateEndX, sectionCreateEndY; - private int sectionOrdinalCounter = 1; - private UUID selectedSectionId = null; - private UUID renamingSectionId = null; - private String sectionRenameBuffer = ""; - /** Like {@link dev.propulsionteam.computed.internal.node.api.elements.WTextField}: selection is active when this differs from cursor. */ - private int sectionRenameCursor = 0; - private int sectionRenameSelectionPos = 0; - private boolean showSectionsSidebar = false; - private static final long SECTION_DOUBLE_CLICK_MS = 280; - /** Compact function library list inside schematic dropdown (similar rhythm to sections sidebar). */ - private static final int FUNCTION_LIB_PANEL_W = 154; - private static final int FUNCTION_LIB_TITLE_H = 11; - private static final int FUNCTION_LIB_NAME_ROW_H = 13; - private static final int FUNCTION_LIB_VISIBLE_ROWS = 5; - /** Inset vertical scrollbar column width (function list, import flyout, item picker). */ - private static final int SCROLLER_TRACK_W = 9; - /** Native size of {@link #ICON_SCROLLER_MULTICOLOR} / {@link #ICON_SCROLLER_DISABLED} (see assets). */ - private static final int SCROLLER_TEX_W = 6; - private static final int SCROLLER_TEX_H = 15; - /** Footer hint icons (scaled up from {@link #ICON_SIZE} atlas cells). */ - private static final int LIBRARY_HINT_ICON = 20; - private static final int FUNCTION_ICON_COLUMN_W = 36; - /** Two stacked hint rows + gap (see {@link #drawFunctionLibraryFooterHints}). */ - private static final int LIBRARY_HINT_BLOCK_H = LIBRARY_HINT_ICON * 2 + 8; - private long lastSectionHeaderClickAtMs = 0; - private UUID lastSectionHeaderClickId = null; - private long lastSidebarSectionClickAtMs = 0; - private UUID lastSidebarSectionClickId = null; - - /** Right-click section → floating RGBA picker (same chrome as Add node). */ - private static final int SECTION_COLOR_PICKER_W = 220; - private static final int SECTION_COLOR_PICKER_H = 204; - /** Screen-space anchor (top-left after clamp), like {@link #menuX}/{@link #menuY}. */ - private int sectionColorPickerX; - private int sectionColorPickerY; - private UUID sectionColorPickerSectionId = null; - private int sectionPickR = 0x1F; - private int sectionPickG = 0x2A; - private int sectionPickB = 0x40; - private int sectionPickA = 0x22; - /** 0–3 = R,G,B,A slider drag; -1 = none. */ - private int sectionPickDragChannel = -1; - - /** {@link dev.propulsionteam.computed.internal.node.api.elements.WItemPickSlot} uses this host to open the picker. */ - private static WNodeScreen activeItemPickHost; - + private int draggingConnection = -1; + private int draggingWaypoint = -1; + private boolean selecting; + private int selectionStartX; + private int selectionStartY; + private int selectionEndX; + private int selectionEndY; + private boolean panning; + private double panLastX; + private double panLastY; + private long panLastNanos; + private int rightPressX = -1; + private int rightPressY = -1; + private long rightPressAt; + private boolean rightDragged; + private int mouseX; + private int mouseY; + private long lastFrameNanos; private boolean itemPickerOpen; private String itemPickerQuery = ""; private int itemPickerScroll; - private java.util.function.Consumer itemPickerCallback; - private static final int ITEM_PICK_PANEL_W = 228; - private static final int ITEM_PICK_ROW_H = 20; - private static final int ITEM_PICK_VISIBLE_ROWS = 9; - private final List itemPickCandidates = new ArrayList<>(); + private Consumer itemPickerCallback; + private final List itemPickerItems = new ArrayList<>(); - // Animation and Effects - private float screenAnimation = 0.0f; - private long lastFrameTimeNs = 0; - /** False after the first {@link #init()} so window resize does not reset undo / replay open animation. */ - private boolean editorFirstInit = true; - /** - * Particle system for the background. - */ - private static class NodeParticle { - double x, y, vx, vy; - int color; - int life, maxLife; + public WNodeScreen(WGraph graph) { + super(Component.literal("Computed Node Editor")); + this.graph = graph; } - private final java.util.List editorParticles = new java.util.ArrayList<>(); - - /** Add-node menu (right-click / Shift+A): hover flyouts + search. */ - private static final int MENU_MAX_VISIBLE = 22; - private static final int MENU_GAP = 2; - - private static final int TOP_BAR_H = 24; - private static final int TOP_BAR_PADDING = 2; - private static final int TOP_BAR_BOTTOM_PADDING = 3; - private static final int TOP_BAR_MENU_BUTTON_W = 19; - private static final int TOP_BAR_BUTTON_GAP = 2; - private static final int CATEGORY_RAIL_W = 40; - private static final int CATEGORY_PANEL_W = 160; - private static final int CATEGORY_BUTTON = 24; - private static final int CATEGORY_ROW_H = 18; - private static final int BOTTOM_BAR_H = 0; - private static final int FUNCTIONS_BUTTON_W = 88; - private static final int SECTIONS_BUTTON_W = 66; - private static final int ACTION_BUTTON_SIZE = 24; - private static final int ACTION_BUTTON_GAP = 4; - private static final int GRID_RIGHT_PADDING = 2; - private static final int GRID_BOTTOM_PADDING = 2; - - private boolean categoryRailVisible = true; - private int paletteCategoryScroll; - private ResourceLocation openPaletteCategory; - private String paletteSearch = ""; - private boolean paletteSearchFocused; - private int paletteScroll; - private int paletteKeyboardIndex; - private BrowseNodeRow pendingPaletteNode; - private int paletteDragStartX; - private int paletteDragStartY; - private boolean paletteDragActivated; - - private boolean shareMenuOpen; - private Component pendingEditorTooltip; - private int pendingEditorTooltipX; - private int pendingEditorTooltipY; - private enum ContextKind { NONE, CANVAS, NODE } - private ContextKind contextKind = ContextKind.NONE; - private int contextAnchorGraphX; - private int contextAnchorGraphY; - private WNode contextNode; - private int rightPressX = -1; - private int rightPressY = -1; - private long rightPressAtMs; - private boolean rightDragPanning; - - private enum ActionButton { SHARE, CENTER, DUPLICATE, DISCONNECT, DELETE } - - private record ActionDockLayout(int x, int y, int width, List buttons) {} - - private boolean anyNodeSelectedForDock() { - if (isSearching) { - return false; - } - for (WNode n : graph.getNodes()) { - if (n.isSelected()) { - return true; - } + public static void requestItemPick(Consumer callback) { + if (activeScreen != null) { + activeScreen.openItemPicker(callback); } - return false; } - /** Safe inset for menus (scales down with small windows / high GUI scale). */ - private int menuEdgeMargin() { - return Math.max(4, Math.min(viewInset(), Math.min(width, height) / 18)); + protected boolean minimalCanvasMode() { + return true; } - private int menuEdgeLeft() { - return menuEdgeMargin(); - } + protected void openNodeExplorer(int screenX, int screenY, int graphX, int graphY) {} - private int menuEdgeRight() { - return width - menuEdgeMargin(); + protected WNode createDuplicateNode(WNode source, int x, int y) { + return null; } - private int menuEdgeTop() { - return menuEdgeMargin(); - } + protected void persistEditorViewport(String contextKey) {} - private int menuEdgeBottom() { - return height - menuEdgeMargin(); + protected boolean loadEditorViewport(String contextKey) { + return false; } - private int menuRowHeight() { - return Math.max(9, font.lineHeight + 2); + protected final void restoreEditorViewport(double panX, double panY, float zoom) { + viewport.restore(panX, panY, zoom); + wires.invalidate(); } - /** Two text lines + padding (title + search line). */ - private int menuHeaderHeight() { - return menuRowHeight() * 2 + 4; + protected final double editorPanX() { + return viewport.panX(); } - private int menuMinColWidth() { - return Math.max(48, width / 12); + protected final double editorPanY() { + return viewport.panY(); } - private boolean isSearching = false; - private String searchQuery = ""; - private int menuX, menuY; - /** Graph-space anchor where new nodes are placed (keyboard confirm / consistent spawn). */ - private int menuAnchorNx, menuAnchorNy; - /** Open submenu chain from root (browse mode only); each entry is a category id. */ - private final java.util.List menuFlyoutPath = new java.util.ArrayList<>(); - /** - * Locked root category for browse mode: only this tree's flyouts are shown until the pointer leaves all - * add-node menu chrome or the menu closes. - */ - private net.minecraft.resources.ResourceLocation stickyBrowseRootId = null; - /** Flat list when search query non-empty. */ - private final java.util.List searchHitRows = new java.util.ArrayList<>(); - - private sealed interface BrowseRow permits BrowseCategoryRow, BrowseNodeRow {} - - private record BrowseCategoryRow(net.minecraft.resources.ResourceLocation id, Component label) implements BrowseRow {} + protected final float editorZoom() { + return viewport.zoom(); + } - private record BrowseNodeRow(net.minecraft.resources.ResourceLocation nodeType, Component label) implements BrowseRow {} + protected final long editorRevision() { + return revision; + } - private record MenuRect(int x, int y, int w, int h) { - boolean contains(int mx, int my) { - return mx >= x && mx < x + w && my >= y && my < y + h; - } + protected final long editorHistoryRevision() { + return history.currentRevision(); } - public WNodeScreen(WGraph graph) { - this(graph, null, null); + protected final boolean editorHistoryDirty() { + return history.isDirty(); } - public WNodeScreen(WGraph graph, FunctionDefinitionStore functionStore) { - this(graph, functionStore, null); + protected final void acknowledgeEditorHistorySaved(long acknowledgedEditGeneration) { + history.markSaved(); } - public WNodeScreen( - WGraph graph, FunctionDefinitionStore functionStore, Predicate editorPeripheralLocked) { - super(Component.literal("Computed Node Editor")); - this.graph = graph; - this.functionStore = functionStore; - this.editorPeripheralLocked = editorPeripheralLocked; - this.editorHistory = new EditorHistory<>(this, MAX_UNDO); + protected final void setEditorSaveFailureDiagnostic(String message) { + saveFailure = message == null ? "" : message; } - /** True when the computer editor should show hardware-missing treatment for this node type. */ - protected boolean isEditorPeripheralLocked(ResourceLocation nodeTypeId) { - return editorPeripheralLocked != null && editorPeripheralLocked.test(nodeTypeId); + protected final void clearEditorSaveFailureDiagnostic() { + saveFailure = ""; } - /** - * When true, the function library row is dimmed and cannot be placed (graph body references a peripheral - * not installed on the computer). - */ - protected boolean isFunctionLibraryDefinitionHardwareLocked(FunctionDefinitionStore.Definition def) { - return false; + protected final int editorGraphX(double screenX) { + return screenToGraphX(screenX); } - /** - * Shown above the function library when the physical computer has in-world linked peripherals (Computed only). - */ - protected List placedPeripheralHudLines() { - return List.of(); + protected final int editorGraphY(double screenY) { + return screenToGraphY(screenY); } - private int functionPickerPlacedSectionHeight() { - List lines = placedPeripheralHudLines(); - if (lines.isEmpty()) { - return 0; - } - int lh = font != null ? font.lineHeight : 9; - return FUNCTION_LIB_TITLE_H + lines.size() * lh + 4; + protected final void adjustEditorZoom(double amount, double screenX, double screenY) { + viewport.addZoomImpulse(amount, screenX, screenY); + wires.invalidate(); } - private boolean innerGraphHasLockedPeripheral(WGraph g) { - for (WNode n : g.getNodes()) { - if (isEditorPeripheralLocked(n.getTypeId())) { - return true; - } - if (n instanceof FunctionCardNode fc && innerGraphHasLockedPeripheral(fc.getInnerGraph())) { - return true; - } + protected final void addNodeToCanvas(WNode node) { + if (node == null) { + return; } - return false; + checkpoint(); + clearSelection(); + graph.addNode(node); + node.setSelected(true); + selectedNode = node; } - private void drawEditorPeripheralLockOverlay(GuiGraphics graphics, WNode node) { - int x = node.getX(); - int y = node.getY(); - int w = node.getWidth(); - int h = node.getHeight(); - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 5); - graphics.fill(x, y, x + w, y + h, ComputedEditorTheme.DANGER_BACKGROUND); - graphics.renderOutline(x, y, w, h, ComputedEditorTheme.STATUS_LOCKED); - graphics.renderOutline(x + 1, y + 1, w - 2, h - 2, ComputedEditorTheme.BORDER_INNER); - String msg = Component.translatable("gui.computed.peripheral_not_available").getString(); - int tw = font.width(msg); - int tx = x + (w - tw) / 2; - int ty = y + (h - font.lineHeight) / 2; - graphics.drawString(font, msg, tx + 1, ty + 1, 0xFF000000, false); - graphics.drawString(font, msg, tx, ty, ComputedEditorTheme.STATUS_LOCKED_TEXT, false); - graphics.pose().popPose(); + protected final void replaceCanvasGraph(WGraph replacement) { + checkpoint(); + graph = replacement; + selectedNode = null; + wires.invalidate(); } - private void enterFunctionGraphEdit(FunctionCardNode host) { - nestedFunctionTestPlaying = false; - persistEditorViewport(editorViewportContextKey()); - functionEditStack.push(new FunctionEditFrame(graph, host)); - graph = host.getInnerGraph(); - graph.updateTopology(); - editorHistory.discardCommands(); - invalidateEditorInfrastructure(); - selectedNode = null; - selectedSectionId = null; - isSearching = false; - clearStickyBrowseRoot(); - clearPendingWireSpawn(); - if (!loadEditorViewport(editorViewportContextKey())) { - applyDefaultViewportForContext(editorViewportContextKey()); - } - playUiClick(1.06f); + protected final boolean hasSelectedNodes() { + return graph.getNodes().stream().anyMatch(WNode::isSelected); } - /** Centers the viewport on Start + End nodes inside the current (inner) graph. */ - private void focusPanOnFunctionBoundaryNodes() { - int minX = Integer.MAX_VALUE; - int minY = Integer.MAX_VALUE; - int maxX = Integer.MIN_VALUE; - int maxY = Integer.MIN_VALUE; - boolean any = false; - for (WNode n : graph.getNodes()) { - if (n instanceof FunctionStartNode || n instanceof FunctionEndNode) { - any = true; - minX = Math.min(minX, n.getX()); - minY = Math.min(minY, n.getY()); - maxX = Math.max(maxX, n.getX() + n.getWidth()); - maxY = Math.max(maxY, n.getY() + n.getHeight()); - } + protected final boolean selectNodeAtGraphPoint(int graphX, int graphY) { + WNode node = topNodeAt(graphX, graphY); + if (node == null) { + return false; } - if (!any) { - return; + if (!node.isSelected()) { + clearSelection(); + node.setSelected(true); } - int cx = (minX + maxX) / 2; - int cy = (minY + maxY) / 2; - panX = width / 2.0 - cx; - panY = height / 2.0 - cy; + selectedNode = node; + return true; } - private void confirmNewFunctionAfterNaming() { - if (functionStore == null) { + protected final void cloneSelectedNodes() { + List selected = selectedNodes(); + if (selected.isEmpty()) { return; } - String name = newFunctionNameBuffer.trim(); - if (name.isEmpty()) { - name = "Function " + (functionStore.size() + 1); + checkpoint(); + clearSelection(); + WNode last = null; + for (WNode source : selected) { + WNode copy = createDuplicateNode(source, source.getX() + 24, source.getY() + 24); + if (copy != null) { + graph.addNode(copy); + copy.setSelected(true); + last = copy; + } } - UUID id = functionStore.addNew(name, FunctionCardNode.newInnerTemplateTag()); - newFunctionNamingOpen = false; - newFunctionNameBuffer = ""; - int gx = screenToGraphX(width / 2.0); - int gy = screenToGraphY(height / 2.0); - recordCheckpointBeforeEdit(); - FunctionCardNode card = FunctionCardNode.createPlaced(gx, gy, id, functionStore); - graph.addNode(card); - enterFunctionGraphEdit(card); + selectedNode = last; } - private void cancelNewFunctionNaming() { - newFunctionNamingOpen = false; - newFunctionNameBuffer = ""; + protected final void unlinkSelectedNodes() { + Set ids = new HashSet<>(); + selectedNodes().forEach(node -> ids.add(node.getId())); + if (!ids.isEmpty()) { + checkpoint(); + graph.disconnectNodes(ids); + } } - private void exitFunctionGraphEdit() { - if (functionEditStack.isEmpty()) { + protected final void removeSelectedNodes() { + List selected = selectedNodes(); + if (selected.isEmpty()) { return; } - nestedFunctionTestPlaying = false; - persistEditorViewport(editorViewportContextKey()); - if (functionStore != null) { - syncCurrentNestedFunctionToStore(); - } - FunctionEditFrame f = functionEditStack.pop(); - graph = f.parentGraph(); - if (functionStore != null) { - f.openedHost().syncPinsFromInner(functionStore); - } else { - f.openedHost().syncPinsFromInner(); - } - graph.updateTopology(); - editorHistory.discardCommands(); - invalidateEditorInfrastructure(); + checkpoint(); + selected.forEach(graph::removeNode); selectedNode = null; - if (!loadEditorViewport(editorViewportContextKey())) { - applyDefaultViewportForContext(editorViewportContextKey()); - } - playUiClick(1.02f); } - private boolean isEditingNestedFunction() { - return !functionEditStack.isEmpty(); + @Override + public void tick() { + super.tick(); } - /** - * Identifies which pan/zoom snapshot applies to the graph currently being edited (root vs a specific - * function body). - */ - protected String editorViewportContextKey() { - if (functionEditStack.isEmpty()) { - return EDITOR_VIEWPORT_ROOT; + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + this.mouseX = mouseX; + this.mouseY = mouseY; + long now = System.nanoTime(); + float delta = lastFrameNanos == 0 + ? 0.016f + : (float) ((now - lastFrameNanos) / 1_000_000_000.0); + lastFrameNanos = now; + viewport.advance(delta, width, height); + wires.advanceAnimation(delta); + graphics.drawManaged(() -> renderCanvas(graphics, mouseX, mouseY, partialTick)); + if (itemPickerOpen) { + renderItemPicker(graphics, mouseX, mouseY); } - return functionEditStack.peek().openedHost().getFunctionId().toString(); - } - - /** Optional hook for client-side viewport persistence (computer editor: disk; default: no-op). */ - protected void persistEditorViewport(String contextKey) {} - - /** - * Optional hook to restore pan/zoom for {@code contextKey}. - * - * @return true if viewport was applied (skips default framing) - */ - protected boolean loadEditorViewport(String contextKey) { - return false; - } - - /** - * Client folder for {@code .nbt} function exports (e.g. {@code config/computed/functions}). When {@code - * null}, save / folder / import controls stay disabled. - */ - protected Path clientNestedFunctionsDirectory() { - return null; + super.render(graphics, mouseX, mouseY, partialTick); } - /** Open an exported-functions folder in the OS file manager (optional override). */ - protected void clientRevealNestedFunctionsFolder(Path directory) {} - - private void applyDefaultViewportForContext(String contextKey) { - if (EDITOR_VIEWPORT_ROOT.equals(contextKey)) { - restoreEditorViewport(0, 0, 1); + private void renderCanvas( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick) { + graphics.fill(0, 0, width, height, ComputedEditorTheme.BACKGROUND_PRIMARY); + graphics.pose().pushPose(); + graphics.pose().translate(width / 2f, height / 2f, 0); + graphics.pose().scale(viewport.zoom(), viewport.zoom(), 1); + graphics.pose().translate( + -width / 2f + viewport.panX(), + -height / 2f + viewport.panY(), + 0); + drawGrid(graphics); + int graphMouseX = screenToGraphX(mouseX); + int graphMouseY = screenToGraphY(mouseY); + boolean mouseInsideCanvas = + mouseX >= 0 && mouseX < width && mouseY >= 0 && mouseY < height; + if (mouseInsideCanvas + && draggingConnection < 0 + && linkingNode == null + && !panning) { + updateWireHover(graphMouseX, graphMouseY); } else { - focusPanOnFunctionBoundaryNodes(); + wires.clearHover(); } - } - - /** Restore pan/zoom from client persistence (e.g. per-computer {@link dev.propulsionteam.computed.client.ComputerEditorScreen}). */ - protected final void restoreEditorViewport(double panX, double panY, float zoom) { - this.panX = panX; - this.panY = panY; - this.zoom = Mth.clamp(zoom, 0.1f, 3.0f); - updateEditorDetailLevel(); - } - - protected final double editorPanX() { - return panX; - } - - protected final double editorPanY() { - return panY; - } - - protected final float editorZoom() { - return zoom; - } - - @Override - public void tick() { - double editorStep = 1.0 / (double) WGraph.MAX_TICK_RATE; - if (nestedFunctionTestPlaying && functionStore != null && isEditingNestedFunction()) { - graph.advanceSimulationInWorld(editorStep); - } else { - graph.advanceSimulation(editorStep); + wires.render( + graphics, + graph, + viewport(), + viewport.zoom(), + revision, + geometryMoving(), + EditorDetailLevel.FULL); + if (linkingNode != null + && linkingPin >= 0 + && linkingPin < linkingNode.getOutputs().size()) { + wires.renderCurve( + graphics, + linkingNode.getX() + linkingNode.getWidth(), + linkingNode.getY() + 18 + linkingPin * 12, + graphMouseX, + graphMouseY, + 0xAAFFFFFF, + 1.5f); + } + List drawNodes = new ArrayList<>(graph.getNodes()); + drawNodes.sort(Comparator.comparing(WNode::isSelected) + .thenComparingInt(WNode::getY) + .thenComparingInt(WNode::getX) + .thenComparing(WNode::getId)); + drawNodes.forEach(node -> node.render(graphics, graphMouseX, graphMouseY, partialTick)); + if (selecting) { + int left = Math.min(selectionStartX, selectionEndX); + int top = Math.min(selectionStartY, selectionEndY); + int right = Math.max(selectionStartX, selectionEndX); + int bottom = Math.max(selectionStartY, selectionEndY); + graphics.fill(left, top, right, bottom, 0x2233AAFF); + graphics.renderOutline(left, top, right - left, bottom - top, 0xFF77CCFF); + } + graphics.pose().popPose(); + if (!saveFailure.isEmpty()) { + graphics.fill(4, height - 17, width - 4, height - 3, 0xDD321818); + graphics.drawString(font, saveFailure, 8, height - 14, 0xFFFF9999, false); } - super.tick(); - } - - /** - * Effective scale for graph content (matches pose stack: open animation eases from 90% to 100% of {@link #zoom}). - * Screen ↔ graph conversions must use this, not raw {@code zoom}, or picking drifts from drawing. - */ - private float editorContentScale() { - float ease = easeOutCubic(Math.min(1.0f, screenAnimation)); - return (0.90f + 0.10f * ease) * zoom; - } - - private int screenToGraphX(double screenX) { - float s = editorContentScale(); - return (int) ((screenX - width / 2.0) / s + width / 2.0 - panX); - } - - private int screenToGraphY(double screenY) { - float s = editorContentScale(); - return (int) ((screenY - height / 2.0) / s + height / 2.0 - panY); - } - - private int viewInset() { - return editorFullscreen ? 0 : VIEW_INSET_NORMAL; - } - - private int fullscreenBtnX() { - return width - viewInset() - FULLSCREEN_BTN - FULLSCREEN_BTN_PAD; - } - - private int fullscreenBtnY() { - return viewInset() + FULLSCREEN_BTN_PAD; } - private boolean fullscreenBtnContains(double mx, double my) { - int x = fullscreenBtnX(); - int y = fullscreenBtnY(); - return mx >= x && mx < x + FULLSCREEN_BTN && my >= y && my < y + FULLSCREEN_BTN; + @Override + public void renderBackground( + GuiGraphics graphics, + int mouseX, + int mouseY, + float partialTick) { + // The editor paints an opaque canvas before Screen#render is invoked. Letting + // the vanilla background pass run here would blur that canvas every frame. } - private void requestCameraCenterOnNodes() { - if (graph.getNodes().isEmpty()) { - playUiClick(0.82f); - return; + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (itemPickerOpen) { + return handleItemPickerClick(mouseX, mouseY, button); } - int minX = Integer.MAX_VALUE; - int minY = Integer.MAX_VALUE; - int maxX = Integer.MIN_VALUE; - int maxY = Integer.MIN_VALUE; - boolean any = false; - for (WNode n : graph.getNodes()) { - // In nested function graphs, boundary helper nodes are often not useful anchors. - if (n instanceof FunctionStartNode || n instanceof FunctionEndNode) { - continue; + int graphX = screenToGraphX(mouseX); + int graphY = screenToGraphY(mouseY); + if (selectedNode != null && selectedNode.hasFocusedElement()) { + boolean handled = recordInteraction(() -> selectedNode.mouseClicked( + graphX - selectedNode.getX(), + graphY - selectedNode.getY(), + button)); + if (handled) { + return true; } - any = true; - minX = Math.min(minX, n.getX()); - minY = Math.min(minY, n.getY()); - maxX = Math.max(maxX, n.getX() + n.getWidth()); - maxY = Math.max(maxY, n.getY() + n.getHeight()); } - if (!any) { - for (WNode n : graph.getNodes()) { - minX = Math.min(minX, n.getX()); - minY = Math.min(minY, n.getY()); - maxX = Math.max(maxX, n.getX() + n.getWidth()); - maxY = Math.max(maxY, n.getY() + n.getHeight()); - } + if (button == 1) { + rightPressX = (int) mouseX; + rightPressY = (int) mouseY; + rightPressAt = net.minecraft.Util.getMillis(); + rightDragged = false; + panLastX = mouseX; + panLastY = mouseY; + panLastNanos = System.nanoTime(); + return true; } - int cx = (minX + maxX) / 2; - int cy = (minY + maxY) / 2; - cameraFocusStartPanX = panX; - cameraFocusStartPanY = panY; - cameraFocusTargetPanX = width / 2.0 - cx; - cameraFocusTargetPanY = height / 2.0 - cy; - double dist = Math.hypot(cameraFocusTargetPanX - cameraFocusStartPanX, cameraFocusTargetPanY - cameraFocusStartPanY); - cameraFocusDurationSec = Mth.clamp(dist / 1200.0, 0.22, 0.60); - cameraFocusElapsedSec = 0.0; - cameraFocusActive = true; - playUiClick(1.02f); - } - - private int sectionsSidebarW() { - return 180; - } - - private int sectionsSidebarX() { - return width - viewInset() - sectionsSidebarW() - 8; - } - - private int sectionsSidebarY() { - return viewInset() + TOP_BAR_H + 4; - } - - private int sectionsSidebarH() { - return Math.max(80, height - viewInset() * 2 - TOP_BAR_H - 8); - } - - private boolean sectionsSidebarContains(double mx, double my) { - if (!showSectionsSidebar) { + if (button == 2) { + panning = true; + viewport.beginPan(); + panLastX = mouseX; + panLastY = mouseY; + panLastNanos = System.nanoTime(); + return true; + } + if (button != 0) { return false; } - int x = sectionsSidebarX(); - int y = sectionsSidebarY(); - return mx >= x && mx < x + sectionsSidebarW() && my >= y && my < y + sectionsSidebarH(); - } - - /** Start/End boundaries draw beneath overlapping logic nodes; selection draws last (on top). */ - private static int nodeDrawLayer(WNode n) { - if (n.isSelected()) { - return 2; + updateWireHover(graphX, graphY); + WireEditorController.Hover hover = wires.hover(); + if (Screen.hasAltDown()) { + if (hover.is(WireEditorController.HoverKind.WAYPOINT)) { + checkpoint(); + removeWaypoint(hover.connectionIndex(), hover.waypointIndex()); + return true; + } + if (hover.is(WireEditorController.HoverKind.INSERT_GHOST) + || hover.is(WireEditorController.HoverKind.CURVE_ONLY)) { + checkpoint(); + graph.getConnections().remove(hover.connectionIndex()); + graph.updateTopology(); + wires.invalidate(); + return true; + } } - if (n instanceof FunctionStartNode || n instanceof FunctionEndNode) { - return 0; + if (hover.is(WireEditorController.HoverKind.INSERT_GHOST)) { + checkpoint(); + insertWaypoint(hover); + return true; } - return 1; - } - - private static void sortNodesForDrawOrder(List nodes) { - nodes.sort( - Comparator.comparingInt(WNodeScreen::nodeDrawLayer) - .thenComparingInt(WNode::getY) - .thenComparingInt(WNode::getX) - .thenComparing(WNode::getId)); - } - - private double graphToScreenX(double graphX) { - float s = editorContentScale(); - return width / 2.0 + s * (graphX + panX - width / 2.0); - } - - private double graphToScreenY(double graphY) { - float s = editorContentScale(); - return height / 2.0 + s * (graphY + panY - height / 2.0); - } - - private void updateEditorDetailLevel() { - EditorDetailLevel next = editorDetailLevel.update(zoom); - if (next != editorDetailLevel && next != EditorDetailLevel.FULL && graph != null) { - graph.getNodes().forEach(WNode::clearElementFocus); + if (hover.is(WireEditorController.HoverKind.WAYPOINT)) { + checkpoint(); + draggingConnection = hover.connectionIndex(); + draggingWaypoint = hover.waypointIndex(); + return true; } - editorDetailLevel = next; - } - - /** Existing detail-dependent gestures finish with their original targets before LOD takes effect. */ - private EditorDetailLevel effectiveDetailLevel() { - if (linkingNode != null || draggingWireConnIdx >= 0) { - return EditorDetailLevel.FULL; + WNode node = topNodeAt(graphX, graphY); + if (node != null) { + int output = node.getPinAt(graphX - node.getX(), graphY - node.getY(), false); + if (output >= 0) { + linkingNode = node; + linkingPin = output; + return true; + } + if (!Screen.hasShiftDown() && !node.isSelected()) { + clearSelection(); + } + node.setSelected(true); + selectedNode = node; + double localX = graphX - node.getX(); + double localY = graphY - node.getY(); + if (node.hasInteractiveElementAt(localX, localY) + && recordInteraction(() -> node.mouseClicked(localX, localY, button))) { + return true; + } + checkpoint(); + draggingNode = node; + dragOffsetX = graphX - node.getX(); + dragOffsetY = graphY - node.getY(); + return true; } - return editorDetailLevel; - } - - private static GraphRect indexedBounds(WNode node) { - return GraphRect.fromPositionAndSize( - node.getX(), node.getY(), Math.max(0, node.getWidth()), Math.max(0, node.getHeight())) - .expanded(NODE_INDEX_PADDING); - } - - private void invalidateEditorInfrastructure() { - indexedGraph = null; - indexedEditorRevision = Long.MIN_VALUE; - nodeSpatialIndex.clear(); - indexedNodeOrder.clear(); - wireController.invalidate(); - diagnosticsController.invalidate(); + if (!Screen.hasShiftDown()) { + clearSelection(); + } + selecting = true; + selectionStartX = graphX; + selectionStartY = graphY; + selectionEndX = graphX; + selectionEndY = graphY; + return true; } - private void ensureNodeSpatialIndex() { - if (indexedGraph == graph - && indexedEditorRevision == editorRevision - && nodeSpatialIndex.size() == graph.getNodes().size()) { - return; + @Override + public boolean mouseDragged( + double mouseX, + double mouseY, + int button, + double dragX, + double dragY) { + if (selectedNode != null + && selectedNode.hasFocusedElement() + && recordInteraction(() -> selectedNode.mouseDragged( + screenToGraphX(mouseX) - selectedNode.getX(), + screenToGraphY(mouseY) - selectedNode.getY(), + button, + dragX / viewport.zoom(), + dragY / viewport.zoom()))) { + return true; } - nodeSpatialIndex.clear(); - indexedNodeOrder.clear(); - int order = 0; - for (WNode node : graph.getNodes()) { - if (spatiallyInitializedNodes.add(node)) { - node.updateLayout(); - } - if (nodeSpatialIndex.get(node.getId()).isPresent()) { - nodeSpatialIndex.update(node.getId(), node, indexedBounds(node)); - } else { - nodeSpatialIndex.insert(node.getId(), node, indexedBounds(node)); + if (button == 1 && rightPressX >= 0) { + if (!rightDragged) { + viewport.beginPan(); } - indexedNodeOrder.put(node.getId(), order++); + rightDragged = true; + panBy(mouseX, mouseY); + return true; } - indexedGraph = graph; - indexedEditorRevision = editorRevision; - } - - private void updateIndexedNode(WNode node) { - if (indexedGraph != graph || node == null) { - return; + if (button == 2 && panning) { + panBy(mouseX, mouseY); + return true; } - if (!nodeSpatialIndex.update(node.getId(), node, indexedBounds(node))) { - nodeSpatialIndex.insert(node.getId(), node, indexedBounds(node)); - indexedNodeOrder.put(node.getId(), graph.getNodes().indexOf(node)); + int graphX = screenToGraphX(mouseX); + int graphY = screenToGraphY(mouseY); + if (draggingConnection >= 0 + && draggingConnection < graph.getConnections().size() + && draggingWaypoint >= 0) { + WConnection connection = graph.getConnections().get(draggingConnection); + int[] xs = connection.waypointXs(); + int[] ys = connection.waypointYs(); + if (draggingWaypoint < xs.length) { + xs[draggingWaypoint] = graphX; + ys[draggingWaypoint] = graphY; + graph.getConnections().set( + draggingConnection, + connection.withWaypoints(xs, ys)); + graph.markConnectionGeometryChanged(); + wires.invalidateHoverCache(); + } + return true; } - } - - private List nodesAtGraphPoint(int graphX, int graphY, boolean topFirst) { - ensureNodeSpatialIndex(); - List result = new ArrayList<>(); - for (UniformGridSpatialIndex.SpatialEntry entry : - nodeSpatialIndex.query(new GraphPoint(graphX, graphY))) { - result.add(entry.value()); + if (selecting) { + selectionEndX = graphX; + selectionEndY = graphY; + return true; } - Comparator order = Comparator.comparingInt(node -> indexedNodeOrder.getOrDefault(node.getId(), -1)); - result.sort(topFirst ? order.reversed() : order); - return result; - } - - private List nodesIntersectingGraphRect(GraphRect area) { - ensureNodeSpatialIndex(); - List result = new ArrayList<>(); - for (UniformGridSpatialIndex.SpatialEntry entry : nodeSpatialIndex.query(area)) { - result.add(entry.value()); - } - return result; - } - - private List visibleNodes(int screenLeft, int screenTop, int screenRight, int screenBottom) { - ensureNodeSpatialIndex(); - int graphLeft = screenToGraphX(screenLeft); - int graphTop = screenToGraphY(screenTop); - int graphRight = screenToGraphX(screenRight); - int graphBottom = screenToGraphY(screenBottom); - GraphRect viewport = new GraphRect( - Math.min(graphLeft, graphRight), - Math.min(graphTop, graphBottom), - Math.max(graphLeft, graphRight), - Math.max(graphTop, graphBottom)) - .expanded(24.0 / Math.max(0.1f, editorContentScale())); - List result = new ArrayList<>(); - for (UniformGridSpatialIndex.SpatialEntry entry : nodeSpatialIndex.query(viewport)) { - result.add(entry.value()); - } - return result; - } - - private GraphRect graphViewport(int screenLeft, int screenTop, int screenRight, int screenBottom, double padding) { - int graphLeft = screenToGraphX(screenLeft); - int graphTop = screenToGraphY(screenTop); - int graphRight = screenToGraphX(screenRight); - int graphBottom = screenToGraphY(screenBottom); - return new GraphRect( - Math.min(graphLeft, graphRight), - Math.min(graphTop, graphBottom), - Math.max(graphLeft, graphRight), - Math.max(graphTop, graphBottom)) - .expanded(padding); - } - - private void refreshEditorDiagnostics() { - if (diagnosticsController.refresh(graph).isEmpty()) { - diagnosticsPanelOpen = false; + if (draggingNode != null) { + int targetX = graphX - (int) dragOffsetX; + int targetY = graphY - (int) dragOffsetY; + int deltaX = targetX - draggingNode.getX(); + int deltaY = targetY - draggingNode.getY(); + if (deltaX != 0 || deltaY != 0) { + List moved = selectedNodes().stream().map(WNode::getId).toList(); + graph.shiftWaypointsForConnectionsTouching(moved, deltaX, deltaY); + selectedNodes().forEach(node -> + node.setPos(node.getX() + deltaX, node.getY() + deltaY)); + wires.invalidate(); + } + return true; } + return false; } - private int sectionsToggleX() { - return width - viewInset() - SECTIONS_BUTTON_W - TOP_BAR_PADDING; - } - - private int sectionsToggleY() { - return viewInset() + TOP_BAR_PADDING; - } - - private int topBarButtonHeight() { - return TOP_BAR_H - TOP_BAR_PADDING - TOP_BAR_BOTTOM_PADDING; - } - - private int categoryRailToggleX() { - return viewInset() + TOP_BAR_PADDING; - } - - private int categoryRailToggleY() { - return viewInset() + TOP_BAR_PADDING; - } - - private boolean categoryRailToggleContains(double mx, double my) { - int x = categoryRailToggleX(); - int y = categoryRailToggleY(); - return mx >= x && mx < x + TOP_BAR_MENU_BUTTON_W - && my >= y && my < y + topBarButtonHeight(); - } - - private boolean sectionsToggleContains(double mx, double my) { - int x = sectionsToggleX(); - int y = sectionsToggleY(); - return mx >= x && mx < x + SECTIONS_BUTTON_W - && my >= y && my < y + topBarButtonHeight(); - } - - private void beginSectionCreate(int nx, int ny) { - isCreatingSection = true; - sectionCreateStartX = nx; - sectionCreateStartY = ny; - sectionCreateEndX = nx; - sectionCreateEndY = ny; - selectedSectionId = null; - } - - /** Section title bar only (same 16px band as the painted header); not the body below. */ - private WGraph.WSection findSectionAt(int nx, int ny) { - WGraph.WSection best = null; - int bestLayer = Integer.MIN_VALUE; - int bestArea = Integer.MAX_VALUE; - for (WGraph.WSection s : graph.getSections()) { - if (nx < s.getX() || nx > s.getX() + s.getWidth() || ny < s.getY() || ny > s.getY() + 16) { - continue; - } - int layer = s.getLayer(); - int area = s.getWidth() * s.getHeight(); - if (layer > bestLayer || (layer == bestLayer && area < bestArea)) { - best = s; - bestLayer = layer; - bestArea = area; + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + int graphX = screenToGraphX(mouseX); + int graphY = screenToGraphY(mouseY); + if (button == 1 && rightPressX >= 0) { + boolean contextClick = !rightDragged && PointerGestureClassifier.isContextClick( + rightPressX, + rightPressY, + rightPressAt, + (int) mouseX, + (int) mouseY, + net.minecraft.Util.getMillis()); + if (contextClick) { + updateWireHover(graphX, graphY); + WireEditorController.Hover hover = wires.hover(); + if (hover.connectionIndex() >= 0 + && hover.connectionIndex() < graph.getConnections().size()) { + checkpoint(); + graph.getConnections().remove(hover.connectionIndex()); + graph.updateTopology(); + wires.invalidate(); + } else { + openNodeExplorer((int) mouseX, (int) mouseY, graphX, graphY); + } } + rightPressX = -1; + rightPressY = -1; + rightDragged = false; + viewport.endPan(); + return true; } - return best; - } - - private static List sectionsSortedByLayer(List src) { - List list = new ArrayList<>(src); - list.sort( - Comparator.comparingInt(WGraph.WSection::getLayer) - .thenComparing(s -> s.getName(), String.CASE_INSENSITIVE_ORDER)); - return list; - } - - /** - * {@code inner}'s rectangle is fully inside {@code outer}'s (different ids). Used for nested “layer” - * sections: copy, drag, and delete move the subtree together. - */ - private static boolean sectionFullyContainedIn(WGraph.WSection inner, WGraph.WSection outer) { - if (inner.getId().equals(outer.getId())) { + if (button == 2) { + panning = false; + viewport.endPan(); + return true; + } + if (button != 0) { return false; } - return inner.getX() >= outer.getX() - && inner.getY() >= outer.getY() - && inner.getX() + inner.getWidth() <= outer.getX() + outer.getWidth() - && inner.getY() + inner.getHeight() <= outer.getY() + outer.getHeight(); - } - - private int sectionHeaderArgb(WGraph.WSection s, boolean renaming, boolean selected) { - if (renaming) { - return 0xEE102018; + if (selecting) { + selectRectangle(); } - int b = s.getBodyColorArgb(); - int r = (b >> 16) & 0xFF; - int g = (b >> 8) & 0xFF; - int bl = b & 0xFF; - int boost = selected ? 35 : 18; - r = Mth.clamp(r + boost, 0, 255); - g = Mth.clamp(g + boost, 0, 255); - bl = Mth.clamp(bl + boost, 0, 255); - int alpha = selected ? 0xAA : 0x88; - return (alpha << 24) | (r << 16) | (g << 8) | bl; - } - - private void clampSectionColorPickerOnScreen() { - int el = menuEdgeLeft(); - int et = menuEdgeTop(); - int maxX = menuEdgeRight() - SECTION_COLOR_PICKER_W; - int maxY = menuEdgeBottom() - SECTION_COLOR_PICKER_H; - if (maxX < el) { - maxX = el; + if (linkingNode != null) { + WNode target = topNodeAt(graphX, graphY); + int input = target == null + ? -1 + : target.getPinAt(graphX - target.getX(), graphY - target.getY(), true); + if (target != null && input >= 0 && compatible(linkingNode, linkingPin, target, input)) { + checkpoint(); + graph.connect(linkingNode.getId(), linkingPin, target.getId(), input); + wires.invalidate(); + } else { + openNodeExplorer((int) mouseX, (int) mouseY, graphX, graphY); + } } - if (maxY < et) { - maxY = et; + if (selectedNode != null) { + selectedNode.mouseReleased(graphX, graphY, button); } - sectionColorPickerX = Mth.clamp(sectionColorPickerX, el, maxX); - sectionColorPickerY = Mth.clamp(sectionColorPickerY, et, maxY); - } - - private void sectionColorPickerPanelOrigin(int[] outXY) { - clampSectionColorPickerOnScreen(); - outXY[0] = sectionColorPickerX; - outXY[1] = sectionColorPickerY; - } - - /** Content area below the green title bar (preview + sliders). */ - private int sectionPickerBodyTop(int py) { - return py + 4 + menuRowHeight() + 6; - } - - private int sectionColorPickerPackArgb() { - return (sectionPickA << 24) | (sectionPickR << 16) | (sectionPickG << 8) | sectionPickB; - } - - private void openSectionColorPicker(WGraph.WSection s, int anchorScreenX, int anchorScreenY) { - sectionColorPickerSectionId = s.getId(); - selectedSectionId = s.getId(); - sectionColorPickerX = anchorScreenX; - sectionColorPickerY = anchorScreenY; - int col = s.getBodyColorArgb(); - sectionPickA = (col >>> 24) & 0xFF; - sectionPickR = (col >> 16) & 0xFF; - sectionPickG = (col >> 8) & 0xFF; - sectionPickB = col & 0xFF; - sectionPickDragChannel = -1; - clampSectionColorPickerOnScreen(); - } - - private void closeSectionColorPicker() { - sectionColorPickerSectionId = null; - sectionPickDragChannel = -1; + selecting = false; + draggingNode = null; + linkingNode = null; + linkingPin = -1; + draggingConnection = -1; + draggingWaypoint = -1; + return true; } - private void applySectionColorPicker() { - if (sectionColorPickerSectionId == null) { - return; - } - recordCheckpointBeforeEdit(); - int argb = sectionColorPickerPackArgb(); - for (WGraph.WSection s : graph.getSections()) { - if (s.getId().equals(sectionColorPickerSectionId)) { - s.setBodyColorArgb(argb); - break; - } + @Override + public boolean mouseScrolled( + double mouseX, + double mouseY, + double scrollX, + double scrollY) { + if (itemPickerOpen) { + itemPickerScroll = Mth.clamp( + itemPickerScroll - (int) Math.signum(scrollY) * 3, + 0, + Math.max(0, filteredItemPickerItems().size() - ITEM_PICKER_ROWS)); + return true; } - closeSectionColorPicker(); - playUiClick(1.03f); - } - - private boolean sectionColorPickerPanelContains(double mx, double my) { - if (sectionColorPickerSectionId == null) { + if (scrollY == 0) { return false; } - int[] o = new int[2]; - sectionColorPickerPanelOrigin(o); - return mx >= o[0] && mx < o[0] + SECTION_COLOR_PICKER_W && my >= o[1] && my < o[1] + SECTION_COLOR_PICKER_H; + viewport.addZoomImpulse(scrollY * ZOOM_STEP, mouseX, mouseY); + wires.invalidate(); + return true; } - private void sectionPickerSetChannelFromMouseX(int channel, double mouseX) { - int[] o = new int[2]; - sectionColorPickerPanelOrigin(o); - int slx = o[0] + 12; - int slw = SECTION_COLOR_PICKER_W - 24; - int v = (int) Mth.clamp((mouseX - slx) / slw * 255.0, 0, 255); - switch (channel) { - case 0 -> sectionPickR = v; - case 1 -> sectionPickG = v; - case 2 -> sectionPickB = v; - case 3 -> sectionPickA = v; - default -> { + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (itemPickerOpen) { + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + closeItemPicker(); + } else if (keyCode == GLFW.GLFW_KEY_BACKSPACE && !itemPickerQuery.isEmpty()) { + itemPickerQuery = itemPickerQuery.substring(0, itemPickerQuery.length() - 1); + itemPickerScroll = 0; } + return true; } - } - - /** @return true if event consumed. */ - private boolean handleSectionColorPickerMouseClick(double mouseX, double mouseY, int button) { - if (sectionColorPickerSectionId == null) { - return false; + if (selectedNode != null + && selectedNode.hasFocusedElement() + && recordInteraction(() -> selectedNode.keyPressed(keyCode, scanCode, modifiers))) { + return true; } - clampSectionColorPickerOnScreen(); - int px = sectionColorPickerX; - int py = sectionColorPickerY; - if (button != 0) { - if (!sectionColorPickerPanelContains(mouseX, mouseY)) { - closeSectionColorPicker(); - } + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_Z) { + undo(); return true; } - if (!sectionColorPickerPanelContains(mouseX, mouseY)) { - closeSectionColorPicker(); + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_Y) { + redo(); return true; } - int bodyTop = sectionPickerBodyTop(py); - int slx = px + 12; - int slw = SECTION_COLOR_PICKER_W - 24; - for (int c = 0; c < 4; c++) { - int sy = bodyTop + 44 + c * 22; - int barTop = sy + 10; - if (mouseX >= slx && mouseX < slx + slw && mouseY >= barTop && mouseY < barTop + 8) { - sectionPickDragChannel = c; - sectionPickerSetChannelFromMouseX(c, mouseX); - playUiClick(0.98f); - return true; - } + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_A) { + graph.getNodes().forEach(node -> node.setSelected(true)); + return true; } - int by = py + SECTION_COLOR_PICKER_H - 30; - if (mouseX >= px + 10 && mouseX < px + 102 && mouseY >= by && mouseY < by + 14) { - applySectionColorPicker(); + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_D) { + cloneSelectedNodes(); return true; } - if (mouseX >= px + 112 && mouseX < px + 208 && mouseY >= by && mouseY < by + 14) { - closeSectionColorPicker(); - playUiClick(0.92f); + if (hasControlDown() && keyCode == GLFW.GLFW_KEY_U) { + unlinkSelectedNodes(); return true; } - int ry = py + SECTION_COLOR_PICKER_H - 12; - if (mouseX >= px + 12 && mouseX < px + 120 && mouseY >= ry && mouseY < ry + 10) { - sectionPickR = (WGraph.WSection.DEFAULT_BODY_COLOR_ARGB >> 16) & 0xFF; - sectionPickG = (WGraph.WSection.DEFAULT_BODY_COLOR_ARGB >> 8) & 0xFF; - sectionPickB = WGraph.WSection.DEFAULT_BODY_COLOR_ARGB & 0xFF; - sectionPickA = (WGraph.WSection.DEFAULT_BODY_COLOR_ARGB >>> 24) & 0xFF; - playUiClick(1.0f); + if (Screen.hasShiftDown() && keyCode == GLFW.GLFW_KEY_A) { + openNodeExplorer( + mouseX, + mouseY, + screenToGraphX(mouseX), + screenToGraphY(mouseY)); return true; } - return true; - } - - private void renderSectionColorPickerOverlay(GuiGraphics graphics) { - if (sectionColorPickerSectionId == null) { - return; + if (keyCode == GLFW.GLFW_KEY_DELETE || keyCode == GLFW.GLFW_KEY_BACKSPACE) { + removeSelectedNodes(); + return true; } - clampSectionColorPickerOnScreen(); - int px = sectionColorPickerX; - int py = sectionColorPickerY; - float zAboveGraph = - 4000f + Math.min(12000f, (float) graph.getNodes().size() * 12f); - graphics.pose().pushPose(); - graphics.pose().translate(0f, 0f, zAboveGraph); - drawMenuPanel(graphics, px, py, SECTION_COLOR_PICKER_W, SECTION_COLOR_PICKER_H); - graphics.drawString(font, "Section color", px + 4, py + 4, ComputedEditorTheme.ACCENT_MUTED, false); - int bodyTop = sectionPickerBodyTop(py); - int preview = sectionColorPickerPackArgb(); - graphics.fill(px + 12, bodyTop, px + 50, bodyTop + 38, preview); - graphics.renderOutline(px + 12, bodyTop, 38, 38, ComputedEditorTheme.BORDER_HIGHLIGHT); - String hex = String.format("#%02X%02X%02X A %02X", sectionPickR, sectionPickG, sectionPickB, sectionPickA); - graphics.drawString(font, hex, px + 56, bodyTop + 12, ComputedEditorTheme.TEXT_SECONDARY, false); + return super.keyPressed(keyCode, scanCode, modifiers); + } - int slx = px + 12; - int slw = SECTION_COLOR_PICKER_W - 24; - String[] labs = {"Red", "Green", "Blue", "Alpha"}; - int[] vals = {sectionPickR, sectionPickG, sectionPickB, sectionPickA}; - for (int c = 0; c < 4; c++) { - int sy = bodyTop + 44 + c * 22; - graphics.drawString(font, labs[c], px + 12, sy, ComputedEditorTheme.TEXT_PRIMARY, false); - graphics.fill(slx, sy + 10, slx + slw, sy + 18, ComputedEditorTheme.BACKGROUND_INPUT); - int fw = (int) (slw * (vals[c] / 255.0)); - int fillCol = switch (c) { - case 0 -> 0xFFFF5555; - case 1 -> 0xFF55FF55; - case 2 -> 0xFF5555FF; - default -> (sectionPickA << 24) | (sectionPickR << 16) | (sectionPickG << 8) | sectionPickB; - }; - if (fw > 0) { - graphics.fill(slx, sy + 10, slx + fw, sy + 18, fillCol); + @Override + public boolean charTyped(char codePoint, int modifiers) { + if (itemPickerOpen) { + if (!Character.isISOControl(codePoint) && itemPickerQuery.length() < 80) { + itemPickerQuery += Character.toLowerCase(codePoint); + itemPickerScroll = 0; } - graphics.renderOutline(slx, sy + 10, slw, 8, ComputedEditorTheme.BORDER_DEFAULT); + return true; } - - int by = py + SECTION_COLOR_PICKER_H - 30; - ComputedEditorStyle.drawButton(graphics, px + 10, by, 92, 14, false, true); - graphics.drawString(font, "OK", px + 44, by + 3, ComputedEditorTheme.TEXT_HEADER, false); - ComputedEditorStyle.drawDangerButton(graphics, px + 112, by, 96, 14, false); - graphics.drawString(font, "Cancel", px + 138, by + 3, ComputedEditorTheme.TEXT_PRIMARY, false); - graphics.drawString( - font, - "Reset theme default", - px + 12, - py + SECTION_COLOR_PICKER_H - 11, - ComputedEditorTheme.TEXT_SECONDARY, - false); - graphics.pose().popPose(); + if (selectedNode != null + && selectedNode.hasFocusedElement() + && recordInteraction(() -> selectedNode.charTyped(codePoint, modifiers))) { + return true; + } + return super.charTyped(codePoint, modifiers); } - /** Called from {@link dev.propulsionteam.computed.internal.node.api.elements.WItemPickSlot} when a node editor is open. */ - public static void requestItemPick(Consumer onChosen) { - if (activeItemPickHost == null || onChosen == null) { - return; + @Override + public void removed() { + persistEditorViewport(EDITOR_VIEWPORT_ROOT); + if (activeScreen == this) { + activeScreen = null; } - activeItemPickHost.openItemPicker(onChosen); + super.removed(); } - private void openItemPicker(Consumer onChosen) { - itemPickerOpen = true; - itemPickerQuery = ""; - itemPickerScroll = 0; - itemPickerCallback = onChosen; - rebuildItemPickCandidates(); - playUiClick(1.01f); + @Override + public void added() { + activeScreen = this; + super.added(); } - private void closeItemPicker() { - itemPickerOpen = false; - itemPickerCallback = null; - itemPickCandidates.clear(); + @Override + public boolean isPauseScreen() { + return false; } - private void rebuildItemPickCandidates() { - itemPickCandidates.clear(); - String q = itemPickerQuery.trim().toLowerCase(); - for (Item it : BuiltInRegistries.ITEM) { - ItemStack st = it.getDefaultInstance(); - if (st.isEmpty()) { - continue; - } - ResourceLocation id = BuiltInRegistries.ITEM.getKey(it); - String ids = id.toString().toLowerCase(); - if (!q.isEmpty() && !ids.contains(q)) { - continue; - } - itemPickCandidates.add(st); - if (itemPickCandidates.size() >= 400) { - break; - } - } + protected final boolean isEditorModalOpen() { + return itemPickerOpen; } - private int itemPickerPanelH() { - return menuHeaderHeight() + 16 + ITEM_PICK_VISIBLE_ROWS * ITEM_PICK_ROW_H + menuRowHeight() + 6; + private void checkpoint() { + revision++; + history.execute(new Snapshot(graph.save())); + wires.invalidate(); } - private int itemPickerPanelX() { - return Mth.clamp(width / 2 - ITEM_PICK_PANEL_W / 2, menuEdgeLeft(), menuEdgeRight() - ITEM_PICK_PANEL_W); + private boolean recordInteraction(BooleanSupplier interaction) { + CompoundTag before = graph.save(); + if (!interaction.getAsBoolean()) { + return false; + } + revision++; + history.execute(new Snapshot(before)); + wires.invalidate(); + return true; } - private int itemPickerPanelY() { - return Mth.clamp(height / 5, menuEdgeTop(), menuEdgeBottom() - itemPickerPanelH()); + private void openItemPicker(Consumer callback) { + itemPickerCallback = callback; + itemPickerQuery = ""; + itemPickerScroll = 0; + itemPickerItems.clear(); + BuiltInRegistries.ITEM.stream() + .map(ItemStack::new) + .sorted(java.util.Comparator.comparing( + stack -> stack.getHoverName().getString(), + String.CASE_INSENSITIVE_ORDER)) + .forEach(itemPickerItems::add); + itemPickerOpen = true; } - private boolean itemPickerContains(double mx, double my) { - if (!itemPickerOpen) { - return false; - } - int px = itemPickerPanelX(); - int py = itemPickerPanelY(); - return mx >= px && mx < px + ITEM_PICK_PANEL_W && my >= py && my < py + itemPickerPanelH(); + private void closeItemPicker() { + itemPickerOpen = false; + itemPickerCallback = null; + itemPickerItems.clear(); + } + + private List filteredItemPickerItems() { + if (itemPickerQuery.isBlank()) { + return itemPickerItems; + } + String query = itemPickerQuery.toLowerCase(java.util.Locale.ROOT); + return itemPickerItems.stream() + .filter(stack -> stack.getHoverName() + .getString() + .toLowerCase(java.util.Locale.ROOT) + .contains(query) + || BuiltInRegistries.ITEM + .getKey(stack.getItem()) + .toString() + .contains(query)) + .toList(); } - private void renderItemPickerOverlay(GuiGraphics graphics) { - if (!itemPickerOpen) { - return; - } + private void renderItemPicker( + GuiGraphics graphics, + int mouseX, + int mouseY) { + int panelHeight = 28 + ITEM_PICKER_ROWS * ITEM_PICKER_ROW_HEIGHT + 8; + int x = (width - ITEM_PICKER_WIDTH) / 2; + int y = (height - panelHeight) / 2; graphics.pose().pushPose(); - graphics.pose().translate(0f, 0f, 5200f); - graphics.fill(0, 0, width, height, ComputedEditorTheme.BACKGROUND_MODAL_SCRIM); - int px = itemPickerPanelX(); - int py = itemPickerPanelY(); - int ph = itemPickerPanelH(); - drawMenuPanel(graphics, px, py, ITEM_PICK_PANEL_W, ph); - graphics.drawString(font, "Pick item (frequency)", px + 6, py + 5, ComputedEditorTheme.ACCENT_MUTED, false); - int lineY = py + menuHeaderHeight(); - graphics.drawString(font, "> " + itemPickerQuery + "_", px + 6, lineY, ComputedEditorTheme.ACCENT, false); - int listTop = lineY + 14; - int listH = ITEM_PICK_VISIBLE_ROWS * ITEM_PICK_ROW_H; - int itemListRight = px + ITEM_PICK_PANEL_W - 2 - SCROLLER_TRACK_W; - int vis = Math.min(ITEM_PICK_VISIBLE_ROWS, Math.max(0, itemPickCandidates.size() - itemPickerScroll)); - graphics.enableScissor(px + 2, listTop, itemListRight, listTop + listH); - for (int row = 0; row < vis; row++) { - int idx = itemPickerScroll + row; - if (idx >= itemPickCandidates.size()) { - break; - } - ItemStack st = itemPickCandidates.get(idx); - int ry = listTop + row * ITEM_PICK_ROW_H; - boolean hr = - mouseX >= px - && mouseX < itemListRight - && mouseY >= ry - && mouseY < ry + ITEM_PICK_ROW_H; - if (hr) { - ComputedEditorStyle.drawMenuRow( - graphics, px + 1, ry, itemListRight - px - 1, ITEM_PICK_ROW_H - 1, true, false); - } - graphics.renderItem(st, px + 6, ry + 2); - String nm = st.getHoverName().getString(); - int maxNmPx = Math.max(font.width("…"), itemListRight - (px + 28) - 4); - if (font.width(nm) > maxNmPx) { - String ell = "…"; - while (nm.length() > 1 && font.width(nm.substring(0, nm.length() - 1) + ell) > maxNmPx) { - nm = nm.substring(0, nm.length() - 1); - } - nm = nm + ell; - } - graphics.drawString(font, nm, px + 28, ry + 6, ComputedEditorTheme.TEXT_PRIMARY, false); - } - graphics.disableScissor(); - drawInsetVerticalScroller( - graphics, - px + ITEM_PICK_PANEL_W - 2 - SCROLLER_TRACK_W, - listTop, - listH, - itemPickerScroll, - itemPickCandidates.size(), - ITEM_PICK_VISIBLE_ROWS); - int footY = listTop + ITEM_PICK_VISIBLE_ROWS * ITEM_PICK_ROW_H + 2; + graphics.pose().translate(0, 0, 7000); + graphics.fill(0, 0, width, height, 0x99000000); + graphics.fill( + x, + y, + x + ITEM_PICKER_WIDTH, + y + panelHeight, + ComputedEditorTheme.MENU_BACKGROUND); + graphics.renderOutline( + x, + y, + ITEM_PICKER_WIDTH, + panelHeight, + ComputedEditorTheme.BORDER_MENU); + graphics.fill( + x + 6, + y + 6, + x + ITEM_PICKER_WIDTH - 6, + y + 22, + ComputedEditorTheme.BACKGROUND_INPUT); graphics.drawString( font, - "Esc: cancel Enter: top match", - px + 6, - footY, - ComputedEditorTheme.TEXT_SECONDARY, + itemPickerQuery.isEmpty() ? "Search items..." : itemPickerQuery + "_", + x + 10, + y + 10, + itemPickerQuery.isEmpty() + ? ComputedEditorTheme.TEXT_TERTIARY + : ComputedEditorTheme.TEXT_PRIMARY, false); + List items = filteredItemPickerItems(); + itemPickerScroll = Mth.clamp( + itemPickerScroll, + 0, + Math.max(0, items.size() - ITEM_PICKER_ROWS)); + for (int row = 0; row < ITEM_PICKER_ROWS && itemPickerScroll + row < items.size(); row++) { + ItemStack stack = items.get(itemPickerScroll + row); + int rowY = y + 28 + row * ITEM_PICKER_ROW_HEIGHT; + boolean hovered = mouseX >= x + 4 + && mouseX < x + ITEM_PICKER_WIDTH - 4 + && mouseY >= rowY + && mouseY < rowY + ITEM_PICKER_ROW_HEIGHT; + if (hovered) { + graphics.fill( + x + 4, + rowY, + x + ITEM_PICKER_WIDTH - 4, + rowY + ITEM_PICKER_ROW_HEIGHT, + ComputedEditorTheme.MENU_HOVER); + } + graphics.renderItem(stack, x + 7, rowY + 2); + graphics.drawString( + font, + stack.getHoverName(), + x + 29, + rowY + 6, + ComputedEditorTheme.TEXT_PRIMARY, + false); + } graphics.pose().popPose(); } private boolean handleItemPickerClick(double mouseX, double mouseY, int button) { - if (!itemPickerOpen) { - return false; - } - if (itemPickerContains(mouseX, mouseY)) { - if (button == 0) { - int listTop = itemPickerPanelY() + menuHeaderHeight() + 14; - int row = (int) ((mouseY - listTop) / ITEM_PICK_ROW_H); - if (row >= 0 && row < ITEM_PICK_VISIBLE_ROWS) { - int idx = itemPickerScroll + row; - if (idx >= 0 && idx < itemPickCandidates.size()) { - ItemStack picked = itemPickCandidates.get(idx).copyWithCount(1); - if (itemPickerCallback != null) { - itemPickerCallback.accept(picked); - } - closeItemPicker(); - playUiClick(1.04f); - return true; - } - } + if (button != 0) { + closeItemPicker(); + return true; + } + int panelHeight = 28 + ITEM_PICKER_ROWS * ITEM_PICKER_ROW_HEIGHT + 8; + int x = (width - ITEM_PICKER_WIDTH) / 2; + int y = (height - panelHeight) / 2; + if (mouseX < x + || mouseX >= x + ITEM_PICKER_WIDTH + || mouseY < y + || mouseY >= y + panelHeight) { + closeItemPicker(); + return true; + } + int row = ((int) mouseY - y - 28) / ITEM_PICKER_ROW_HEIGHT; + List items = filteredItemPickerItems(); + int index = itemPickerScroll + row; + if (row >= 0 && row < ITEM_PICKER_ROWS && index >= 0 && index < items.size()) { + CompoundTag before = graph.save(); + Consumer callback = itemPickerCallback; + ItemStack selected = items.get(index).copyWithCount(1); + closeItemPicker(); + if (callback != null) { + callback.accept(selected); + revision++; + history.execute(new Snapshot(before)); + wires.invalidate(); } - return true; } - closeItemPicker(); - playUiClick(0.92f); return true; } - private int sectionResizeHitSlop() { - return Math.max(5, Mth.ceil(8.0f / editorContentScale())); - } - - /** Hit-test resize handles for the selected section (graph coordinates). */ - private SectionResizeHandle hitSectionResizeHandle(WGraph.WSection s, int nx, int ny) { - if (selectedSectionId == null || !s.getId().equals(selectedSectionId)) { - return SectionResizeHandle.NONE; - } - int d = sectionResizeHitSlop(); - int x = s.getX(); - int y = s.getY(); - int w = s.getWidth(); - int h = s.getHeight(); - int right = x + w; - int bottom = y + h; - boolean onRight = nx >= right - d && nx <= right + d; - boolean onLeft = nx >= x - d && nx <= x + d; - boolean onBottom = ny >= bottom - d && ny <= bottom + d; - if (onBottom && onRight) { - return SectionResizeHandle.SE; - } - if (onBottom && onLeft) { - return SectionResizeHandle.SW; - } - if (onBottom && nx > x + d && nx < right - d) { - return SectionResizeHandle.S; - } - if (onRight && ny > y + d && ny < bottom - d) { - return SectionResizeHandle.E; - } - if (onLeft && ny > y + d && ny < bottom - d) { - return SectionResizeHandle.W; + private void undo() { + if (history.undo()) { + revision++; + wires.invalidate(); } - return SectionResizeHandle.NONE; - } - - private void drawSectionResizeHandles(GuiGraphics graphics, WGraph.WSection s) { - int x = s.getX(); - int y = s.getY(); - int w = s.getWidth(); - int h = s.getHeight(); - int midY = y + h / 2; - int midX = x + w / 2; - int bot = y + h; - int right = x + w; - int half = Math.max(2, Mth.floor(3.5f * editorContentScale())); - drawResizeHandleSquare(graphics, right, bot, half); - drawResizeHandleSquare(graphics, x, bot, half); - drawResizeHandleSquare(graphics, midX, bot, half); - drawResizeHandleSquare(graphics, right, midY, half); - drawResizeHandleSquare(graphics, x, midY, half); - } - - private void drawResizeHandleSquare(GuiGraphics graphics, int cx, int cy, int half) { - graphics.fill(cx - half, cy - half, cx + half + 1, cy + half + 1, 0xFFE8ECFF); - graphics.renderOutline(cx - half, cy - half, half * 2 + 1, half * 2 + 1, 0xFF6C8DFF); - } - - private static final long SECTION_RENAME_CARET_BLINK_MS = 520L; - - private boolean sectionRenameCaretLit() { - return (net.minecraft.Util.getMillis() / SECTION_RENAME_CARET_BLINK_MS) % 2L == 0L; } - /** - * Blinking insertion caret after inline rename text. {@code textY} must match the y passed to - * {@link GuiGraphics#drawString(net.minecraft.client.gui.Font, String, int, int, int, boolean)} for that label. - */ - private void drawSectionRenameCaret( - GuiGraphics graphics, int textLeftX, int textY, int textMaxRightX, String textBeforeCaret) { - if (!sectionRenameCaretLit()) { - return; - } - int cx = textLeftX + font.width(textBeforeCaret); - if (cx > textMaxRightX - 1) { - return; + private void redo() { + if (history.redo()) { + revision++; + wires.invalidate(); } - int h = Math.max(8, font.lineHeight); - graphics.fill(cx, textY, cx + 1, textY + h, 0xFFFFFFFF); - } - - private void startSectionRename(UUID sectionId, String name) { - endLibraryFunctionRenameEditing(); - renamingSectionId = sectionId; - sectionRenameBuffer = name == null ? "" : name; - sectionRenameCursor = sectionRenameBuffer.length(); - sectionRenameSelectionPos = sectionRenameCursor; } - private void startLibraryFunctionRename(UUID functionId, String name) { - endSectionRenameEditing(); - renamingLibraryFunctionId = functionId; - libraryFnRenameBuffer = name == null ? "" : name; - libraryFnRenameCursor = libraryFnRenameBuffer.length(); - libraryFnRenameSelectionPos = libraryFnRenameCursor; + private void clearSelection() { + graph.getNodes().forEach(node -> node.setSelected(false)); + selectedNode = null; } - private void endLibraryFunctionRenameEditing() { - renamingLibraryFunctionId = null; - libraryFnRenameBuffer = ""; - libraryFnRenameCursor = 0; - libraryFnRenameSelectionPos = 0; + private List selectedNodes() { + return graph.getNodes().stream().filter(WNode::isSelected).toList(); } - private void commitLibraryFunctionRename() { - if (renamingLibraryFunctionId == null || functionStore == null) { - endLibraryFunctionRenameEditing(); - return; - } - FunctionDefinitionStore.Definition def = functionStore.get(renamingLibraryFunctionId); - if (def == null) { - endLibraryFunctionRenameEditing(); - return; - } - String nm = libraryFnRenameBuffer.trim(); - if (nm.isEmpty()) { - nm = def.name(); + private WNode topNodeAt(int graphX, int graphY) { + List nodes = graph.getNodes(); + for (int index = nodes.size() - 1; index >= 0; index--) { + WNode node = nodes.get(index); + node.ensureLayoutUpToDate(); + if (graphX >= node.getX() - 5 + && graphX <= node.getX() + node.getWidth() + 5 + && graphY >= node.getY() + && graphY <= node.getY() + node.getHeight()) { + return node; + } } - recordCheckpointBeforeEdit(); - functionStore.put(renamingLibraryFunctionId, nm, def.body()); - refreshFunctionCardTitlesFromLibrary(); - endLibraryFunctionRenameEditing(); + return null; } - /** Updates {@link FunctionCardNode} titles on the root graph after a library rename. */ - private void refreshFunctionCardTitlesFromLibrary() { - if (functionStore == null) { - return; - } - WGraph root = rootGraphForLibraryCards(); - for (WNode n : root.getNodes()) { - if (n instanceof FunctionCardNode c) { - c.syncPinsFromInner(functionStore); + private void selectRectangle() { + int left = Math.min(selectionStartX, selectionEndX); + int top = Math.min(selectionStartY, selectionEndY); + int right = Math.max(selectionStartX, selectionEndX); + int bottom = Math.max(selectionStartY, selectionEndY); + for (WNode node : graph.getNodes()) { + node.ensureLayoutUpToDate(); + if (node.getX() + node.getWidth() >= left + && node.getX() <= right + && node.getY() + node.getHeight() >= top + && node.getY() <= bottom) { + node.setSelected(true); } } } - private WGraph rootGraphForLibraryCards() { - if (functionEditStack.isEmpty()) { - return graph; - } - return functionEditStack.peekLast().parentGraph(); + private void panBy(double mouseX, double mouseY) { + long now = System.nanoTime(); + double elapsed = panLastNanos == 0 + ? 1.0 / 60.0 + : (now - panLastNanos) / 1_000_000_000.0; + viewport.dragPan(mouseX - panLastX, mouseY - panLastY, elapsed); + panLastX = mouseX; + panLastY = mouseY; + panLastNanos = now; + wires.invalidateHoverCache(); } - private boolean libraryFnRenameHasSelection() { - return libraryFnRenameCursor != libraryFnRenameSelectionPos; + private void updateWireHover(int graphX, int graphY) { + wires.updateHover( + graph, + graphX, + graphY, + viewport.zoom(), + revision, + geometryMoving(), + point -> blocksWire(point)); } - private void libraryFnRenameDeleteSelection() { - int start = Math.min(libraryFnRenameCursor, libraryFnRenameSelectionPos); - int end = Math.max(libraryFnRenameCursor, libraryFnRenameSelectionPos); - libraryFnRenameBuffer = libraryFnRenameBuffer.substring(0, start) + libraryFnRenameBuffer.substring(end); - libraryFnRenameCursor = start; - libraryFnRenameSelectionPos = start; + private boolean blocksWire(GraphPoint point) { + return topNodeAt((int) point.x(), (int) point.y()) != null; } - private void libraryFnRenameReplaceSelection(String text) { - if (text == null) { - text = ""; - } - libraryFnRenameDeleteSelection(); - libraryFnRenameBuffer = - libraryFnRenameBuffer.substring(0, libraryFnRenameCursor) - + text - + libraryFnRenameBuffer.substring(libraryFnRenameCursor); - libraryFnRenameCursor += text.length(); - libraryFnRenameSelectionPos = libraryFnRenameCursor; + private boolean geometryMoving() { + return draggingNode != null || draggingConnection >= 0; } - private void libraryFnRenameMoveCursor(int nextPos, boolean keepSelection) { - libraryFnRenameCursor = Mth.clamp(nextPos, 0, libraryFnRenameBuffer.length()); - if (!keepSelection) { - libraryFnRenameSelectionPos = libraryFnRenameCursor; - } + private GraphRect viewport() { + double left = screenToGraphX(0) - 36 / viewport.zoom(); + double top = screenToGraphY(0) - 36 / viewport.zoom(); + double right = screenToGraphX(width) + 36 / viewport.zoom(); + double bottom = screenToGraphY(height) + 36 / viewport.zoom(); + return new GraphRect(left, top, right, bottom); } - private int libraryFnRenamePreviousWordBoundary(int from) { - int i = Mth.clamp(from, 0, libraryFnRenameBuffer.length()); - while (i > 0 && Character.isWhitespace(libraryFnRenameBuffer.charAt(i - 1))) { - i--; - } - while (i > 0 && !Character.isWhitespace(libraryFnRenameBuffer.charAt(i - 1))) { - i--; - } - return i; + private int screenToGraphX(double screenX) { + return (int) viewport.graphX(screenX, width); } - private int libraryFnRenameNextWordBoundary(int from) { - int len = libraryFnRenameBuffer.length(); - int i = Mth.clamp(from, 0, len); - while (i < len && Character.isWhitespace(libraryFnRenameBuffer.charAt(i))) { - i++; - } - while (i < len && !Character.isWhitespace(libraryFnRenameBuffer.charAt(i))) { - i++; - } - return i; + private int screenToGraphY(double screenY) { + return (int) viewport.graphY(screenY, height); } - private boolean handleLibraryFunctionRenameKey(int keyCode, int scanCode, int modifiers) { - boolean ctrl = hasControlDown(); - boolean shift = Screen.hasShiftDown(); - if (keyCode == GLFW.GLFW_KEY_LEFT_SHIFT - || keyCode == GLFW.GLFW_KEY_RIGHT_SHIFT - || keyCode == GLFW.GLFW_KEY_LEFT_CONTROL - || keyCode == GLFW.GLFW_KEY_RIGHT_CONTROL - || keyCode == GLFW.GLFW_KEY_LEFT_ALT - || keyCode == GLFW.GLFW_KEY_RIGHT_ALT - || keyCode == GLFW.GLFW_KEY_LEFT_SUPER - || keyCode == GLFW.GLFW_KEY_RIGHT_SUPER) { - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_A) { - libraryFnRenameSelectionPos = 0; - libraryFnRenameCursor = libraryFnRenameBuffer.length(); - playUiClick(0.97f); - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_C) { - if (libraryFnRenameHasSelection()) { - int a = Math.min(libraryFnRenameCursor, libraryFnRenameSelectionPos); - int b = Math.max(libraryFnRenameCursor, libraryFnRenameSelectionPos); - minecraft.keyboardHandler.setClipboard(libraryFnRenameBuffer.substring(a, b)); - } else { - minecraft.keyboardHandler.setClipboard(libraryFnRenameBuffer); - } - playUiClick(1.02f); - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_X) { - if (libraryFnRenameHasSelection()) { - int a = Math.min(libraryFnRenameCursor, libraryFnRenameSelectionPos); - int b = Math.max(libraryFnRenameCursor, libraryFnRenameSelectionPos); - minecraft.keyboardHandler.setClipboard(libraryFnRenameBuffer.substring(a, b)); - libraryFnRenameDeleteSelection(); - playUiClick(0.9f); - } - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_V) { - String clip = minecraft.keyboardHandler.getClipboard(); - if (clip != null && !clip.isEmpty()) { - libraryFnRenameReplaceSelection(sectionRenameSanitizePaste(clip)); - playUiClick(1.04f); - } - return true; + private void drawGrid(GuiGraphics graphics) { + int left = screenToGraphX(0) - GRID_SPACING; + int right = screenToGraphX(width) + GRID_SPACING; + int top = screenToGraphY(0) - GRID_SPACING; + int bottom = screenToGraphY(height) + GRID_SPACING; + int startX = Math.floorDiv(left, GRID_SPACING) * GRID_SPACING; + int startY = Math.floorDiv(top, GRID_SPACING) * GRID_SPACING; + for (int x = startX; x <= right; x += GRID_SPACING) { + graphics.fill(x, top, x + 1, bottom, 0x142F3940); } - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - commitLibraryFunctionRename(); - playUiClick(1.0f); - return true; + for (int y = startY; y <= bottom; y += GRID_SPACING) { + graphics.fill(left, y, right, y + 1, 0x142F3940); } - if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - endLibraryFunctionRenameEditing(); - playUiClick(0.94f); - return true; + } + + private static boolean compatible(WNode source, int output, WNode target, int input) { + if (output < 0 + || output >= source.getOutputs().size() + || input < 0 + || input >= target.getInputs().size()) { + return false; } - if (keyCode == GLFW.GLFW_KEY_BACKSPACE) { - if (libraryFnRenameHasSelection()) { - libraryFnRenameDeleteSelection(); - } else if (libraryFnRenameCursor > 0) { - int start = ctrl ? libraryFnRenamePreviousWordBoundary(libraryFnRenameCursor) : libraryFnRenameCursor - 1; - libraryFnRenameBuffer = - libraryFnRenameBuffer.substring(0, start) + libraryFnRenameBuffer.substring(libraryFnRenameCursor); - libraryFnRenameCursor = start; - libraryFnRenameSelectionPos = libraryFnRenameCursor; - } - playUiClick(0.9f); - return true; + WPin.DataType sourceType = source.getOutputs().get(output).getDataType(); + WPin.DataType targetType = target.getInputs().get(input).getDataType(); + return sourceType == targetType + || sourceType == WPin.DataType.NUMBER && targetType == WPin.DataType.STRING; + } + + private void insertWaypoint(WireEditorController.Hover hover) { + WConnection connection = graph.getConnections().get(hover.connectionIndex()); + int[] oldX = connection.waypointXs(); + int[] oldY = connection.waypointYs(); + int index = Mth.clamp(hover.insertionSegment(), 0, oldX.length); + int[] nextX = new int[oldX.length + 1]; + int[] nextY = new int[oldY.length + 1]; + System.arraycopy(oldX, 0, nextX, 0, index); + System.arraycopy(oldY, 0, nextY, 0, index); + nextX[index] = hover.insertionX(); + nextY[index] = hover.insertionY(); + System.arraycopy(oldX, index, nextX, index + 1, oldX.length - index); + System.arraycopy(oldY, index, nextY, index + 1, oldY.length - index); + graph.getConnections().set( + hover.connectionIndex(), + connection.withWaypoints(nextX, nextY)); + graph.markConnectionGeometryChanged(); + wires.invalidate(); + } + + private void removeWaypoint(int connectionIndex, int waypointIndex) { + WConnection connection = graph.getConnections().get(connectionIndex); + int[] oldX = connection.waypointXs(); + int[] oldY = connection.waypointYs(); + if (waypointIndex < 0 || waypointIndex >= oldX.length) { + return; } - if (keyCode == GLFW.GLFW_KEY_DELETE) { - if (libraryFnRenameHasSelection()) { - libraryFnRenameDeleteSelection(); - } else if (libraryFnRenameCursor < libraryFnRenameBuffer.length()) { - int end = ctrl ? libraryFnRenameNextWordBoundary(libraryFnRenameCursor) : libraryFnRenameCursor + 1; - libraryFnRenameBuffer = - libraryFnRenameBuffer.substring(0, libraryFnRenameCursor) + libraryFnRenameBuffer.substring(end); + int[] nextX = new int[oldX.length - 1]; + int[] nextY = new int[oldY.length - 1]; + for (int source = 0, target = 0; source < oldX.length; source++) { + if (source != waypointIndex) { + nextX[target] = oldX[source]; + nextY[target++] = oldY[source]; } - playUiClick(0.9f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_LEFT) { - int next = - ctrl ? libraryFnRenamePreviousWordBoundary(libraryFnRenameCursor) : Math.max(0, libraryFnRenameCursor - 1); - libraryFnRenameMoveCursor(next, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_RIGHT) { - int next = - ctrl - ? libraryFnRenameNextWordBoundary(libraryFnRenameCursor) - : Math.min(libraryFnRenameBuffer.length(), libraryFnRenameCursor + 1); - libraryFnRenameMoveCursor(next, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_HOME) { - libraryFnRenameMoveCursor(0, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_END) { - libraryFnRenameMoveCursor(libraryFnRenameBuffer.length(), shift); - return true; } - return true; + graph.getConnections().set( + connectionIndex, + connection.withWaypoints(nextX, nextY)); + graph.markConnectionGeometryChanged(); + wires.invalidate(); } - private void endSectionRenameEditing() { - renamingSectionId = null; - sectionRenameBuffer = ""; - sectionRenameCursor = 0; - sectionRenameSelectionPos = 0; - } + private final class Snapshot implements EditorCommand { + private final CompoundTag before; + private CompoundTag after; - private boolean sectionRenameHasSelection() { - return sectionRenameCursor != sectionRenameSelectionPos; - } + private Snapshot(CompoundTag before) { + this.before = before.copy(); + } - private void sectionRenameDeleteSelection() { - int start = Math.min(sectionRenameCursor, sectionRenameSelectionPos); - int end = Math.max(sectionRenameCursor, sectionRenameSelectionPos); - sectionRenameBuffer = sectionRenameBuffer.substring(0, start) + sectionRenameBuffer.substring(end); - sectionRenameCursor = start; - sectionRenameSelectionPos = start; - } + @Override + public void execute(WNodeScreen screen) {} - private void sectionRenameReplaceSelection(String text) { - if (text == null) { - text = ""; - } - sectionRenameDeleteSelection(); - sectionRenameBuffer = - sectionRenameBuffer.substring(0, sectionRenameCursor) + text + sectionRenameBuffer.substring(sectionRenameCursor); - sectionRenameCursor += text.length(); - sectionRenameSelectionPos = sectionRenameCursor; - } - - private void sectionRenameMoveCursor(int nextPos, boolean keepSelection) { - sectionRenameCursor = Mth.clamp(nextPos, 0, sectionRenameBuffer.length()); - if (!keepSelection) { - sectionRenameSelectionPos = sectionRenameCursor; - } - } - - private int sectionRenamePreviousWordBoundary(int from) { - int i = Mth.clamp(from, 0, sectionRenameBuffer.length()); - while (i > 0 && Character.isWhitespace(sectionRenameBuffer.charAt(i - 1))) { - i--; - } - while (i > 0 && !Character.isWhitespace(sectionRenameBuffer.charAt(i - 1))) { - i--; - } - return i; - } - - private int sectionRenameNextWordBoundary(int from) { - int len = sectionRenameBuffer.length(); - int i = Mth.clamp(from, 0, len); - while (i < len && Character.isWhitespace(sectionRenameBuffer.charAt(i))) { - i++; - } - while (i < len && !Character.isWhitespace(sectionRenameBuffer.charAt(i))) { - i++; - } - return i; - } - - private static String sectionRenameSanitizePaste(String clip) { - if (clip == null || clip.isEmpty()) { - return ""; - } - int n = clip.indexOf('\n'); - int r = clip.indexOf('\r'); - int cut = clip.length(); - if (n >= 0) { - cut = Math.min(cut, n); - } - if (r >= 0) { - cut = Math.min(cut, r); - } - return clip.substring(0, cut); - } - - private void drawSectionRenameTextWithSelectionAndCaret(GuiGraphics graphics, int lx, int ly, int maxTextRight) { - int selStart = Math.min(sectionRenameCursor, sectionRenameSelectionPos); - int selEnd = Math.max(sectionRenameCursor, sectionRenameSelectionPos); - if (selStart != selEnd) { - int left = lx + font.width(sectionRenameBuffer.substring(0, selStart)); - int right = lx + font.width(sectionRenameBuffer.substring(0, selEnd)); - left = Math.min(left, maxTextRight); - right = Math.min(right, maxTextRight); - if (left < right) { - graphics.fill(left, ly, right, ly + Math.max(8, font.lineHeight), 0x664A90FF); - } - } - graphics.drawString(font, sectionRenameBuffer, lx, ly, 0xFFCCEEDD, false); - drawSectionRenameCaret(graphics, lx, ly, maxTextRight, sectionRenameBuffer.substring(0, sectionRenameCursor)); - } - - private void finalizeSectionCreate() { - int x1 = Math.min(sectionCreateStartX, sectionCreateEndX); - int y1 = Math.min(sectionCreateStartY, sectionCreateEndY); - int x2 = Math.max(sectionCreateStartX, sectionCreateEndX); - int y2 = Math.max(sectionCreateStartY, sectionCreateEndY); - int w = x2 - x1; - int h = y2 - y1; - isCreatingSection = false; - if (w < MIN_SECTION_W || h < MIN_SECTION_H) { - return; - } - recordCheckpointBeforeEdit(); - WGraph.WSection s = new WGraph.WSection("Section " + sectionOrdinalCounter++, x1, y1, w, h); - graph.getSections().add(s); - int parentMaxLayer = -1; - for (WGraph.WSection p : graph.getSections()) { - if (p.getId().equals(s.getId())) { - continue; - } - if (sectionFullyContainedIn(s, p)) { - parentMaxLayer = Math.max(parentMaxLayer, p.getLayer()); - } - } - s.setLayer(parentMaxLayer < 0 ? 0 : parentMaxLayer + 1); - selectedSectionId = s.getId(); - startSectionRename(s.getId(), s.getName()); - showSectionsSidebar = true; - playUiClick(1.02f); - } - - private void playUiClick(float pitch) { - if (minecraft == null) { - return; - } - minecraft.getSoundManager() - .play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK.value(), pitch)); - } - - @Override - protected void init() { - super.init(); - activeItemPickHost = this; - if (editorFirstInit) { - editorFirstInit = false; - screenAnimation = 0; - editorHistory.discardCommands(); - } - graph.updateTopology(); - invalidateEditorInfrastructure(); - } - - @Override - public void resize(Minecraft minecraft, int width, int height) { - int prevW = this.width; - int prevH = this.height; - super.resize(minecraft, width, height); - if (prevW > 0 && prevH > 0) { - panX += (width - prevW) * 0.5; - panY += (height - prevH) * 0.5; - } - } - - /** - * Snapshot-backed command used while the legacy mutable editor is incrementally moved to - * operation-specific commands. Its post-edit state is captured lazily on first undo. - */ - private static final class GraphSnapshotCommand implements EditorCommand { - private final CompoundTag before; - private CompoundTag after; - - private GraphSnapshotCommand(CompoundTag before) { - this.before = before.copy(); - } - - @Override - public void execute(WNodeScreen screen) { - // The existing editor performs the mutation immediately after recording its checkpoint. - } - - @Override - public void undo(WNodeScreen screen) { - if (after == null) { - after = screen.graph.save().copy(); - } - screen.restoreHistorySnapshot(before); + @Override + public void undo(WNodeScreen screen) { + after = screen.graph.save(); + screen.graph.load(before.copy()); } @Override public void redo(WNodeScreen screen) { - if (after == null) { - throw new IllegalStateException("Cannot redo a graph snapshot before it has been undone"); - } - screen.restoreHistorySnapshot(after); + screen.graph.load(after.copy()); } @Override public String description() { - return "Graph edit"; - } - } - - /** Call immediately before a user edit that should be reversible. */ - private void recordCheckpointBeforeEdit() { - if (historySuspended) { - return; - } - editorRevision++; - editorHistory.execute(new GraphSnapshotCommand(graph.save())); - invalidateEditorInfrastructure(); - } - - private void undo() { - if (!editorHistory.canUndo()) { - return; - } - historySuspended = true; - try { - if (editorHistory.undo()) { - editorRevision++; - } - } finally { - historySuspended = false; - } - } - - private void redo() { - if (!editorHistory.canRedo()) { - return; - } - historySuspended = true; - try { - if (editorHistory.redo()) { - editorRevision++; - } - } finally { - historySuspended = false; - } - } - - private void restoreHistorySnapshot(CompoundTag snapshot) { - graph.load(snapshot.copy()); - selectedNode = null; - draggingNode = null; - linkingNode = null; - linkingPin = -1; - clearPendingWireSpawn(); - isSearching = false; - searchQuery = ""; - menuFlyoutPath.clear(); - clearStickyBrowseRoot(); - closeItemPicker(); - invalidateEditorInfrastructure(); - } - - /** Current local edit generation; zero means the graph has not been edited since opening. */ - protected final long editorRevision() { - return editorRevision; - } - - /** State revision used by history-aware autosave and UI dirty indicators. */ - protected final long editorHistoryRevision() { - return editorHistory.currentRevision(); - } - - protected final boolean editorHistoryDirty() { - return editorHistory.isDirty(); - } - - /** Marks the current history state saved only when the acknowledgement is not stale. */ - protected final void acknowledgeEditorHistorySaved(long acknowledgedEditGeneration) { - if (acknowledgedEditGeneration == editorRevision) { - editorHistory.markSaved(); - } - } - - protected final void setEditorSaveFailureDiagnostic(String message) { - diagnosticsController.setSaveFailure(message); - } - - protected final void clearEditorSaveFailureDiagnostic() { - diagnosticsController.clearSaveFailure(); - } - - /** - * Undo/redo using the key's layout label ({@link GLFW#glfwGetKeyName}) so e.g. QWERTZ Ctrl+Z / Ctrl+Y match - * the printed letters; falls back to US QWERTY key positions if the name is unavailable. - */ - private boolean tryHandleUndoRedo(int keyCode, int scanCode) { - if (!hasControlDown()) { - return false; - } - boolean shift = hasShiftDown(); - if (scanCode != 0) { - String keyName = GLFW.glfwGetKeyName(GLFW.GLFW_KEY_UNKNOWN, scanCode); - if (keyName != null && !keyName.isEmpty()) { - int cp = keyName.codePointAt(0); - if (!Character.isLetter(cp)) { - return false; - } - int lower = Character.toLowerCase(cp); - if (lower == 'z') { - if (shift) { - redo(); - } else { - undo(); - } - return true; - } - if (lower == 'y' && !shift) { - redo(); - return true; - } - return false; - } - } - if (keyCode == GLFW.GLFW_KEY_Z) { - if (shift) { - redo(); - } else { - undo(); - } - return true; - } - if (keyCode == GLFW.GLFW_KEY_Y && !shift) { - redo(); - return true; - } - return false; - } - - @Override - public void removed() { - clearPendingWireSpawn(); - if (activeItemPickHost == this) { - activeItemPickHost = null; - } - closeItemPicker(); - super.removed(); - } - - @Override - public boolean isPauseScreen() { - return false; - } - - private boolean isInsideEditorPanel(double mouseX, double mouseY) { - int inset = viewInset(); - return mouseX >= inset && mouseX < width - inset && mouseY >= inset && mouseY < height - inset; - } - - private int gridRight() { - return width - viewInset() - GRID_RIGHT_PADDING; - } - - private int gridBottom() { - return height - viewInset() - GRID_BOTTOM_PADDING; - } - - private int paletteWidth() { - return categoryRailVisible ? CATEGORY_RAIL_W + (openPaletteCategory == null ? 0 : CATEGORY_PANEL_W) : 0; - } - - private boolean isCanvasPoint(double x, double y) { - int inset = viewInset(); - return isInsideEditorPanel(x, y) - && x >= inset + paletteWidth() - && y >= inset + TOP_BAR_H - && x < gridRight() - && y < gridBottom(); - } - - private record PaletteDisplayRow(Component label, BrowseNodeRow node, int depth) {} - - private List paletteRows() { - List rows = new ArrayList<>(); - if (!paletteSearch.isBlank()) { - for (NodeMenuRegistry.MenuEntry entry : NodeMenuRegistry.filterEntries(paletteSearch)) { - rows.add(new PaletteDisplayRow(entry.label(), new BrowseNodeRow(entry.nodeType(), entry.label()), 0)); - } - return rows; - } - if (openPaletteCategory != null) appendPaletteCategory(rows, openPaletteCategory, 0, false); - return rows; - } - - private void appendPaletteCategory( - List rows, ResourceLocation category, int depth, boolean includeHeader) { - NodeMenuRegistry.Category definition = NodeMenuRegistry.getCategory(category); - if (includeHeader && definition != null) rows.add(new PaletteDisplayRow(definition.title(), null, depth)); - for (NodeMenuRegistry.MenuEntry entry : NodeMenuRegistry.getEntriesIn(category)) { - rows.add(new PaletteDisplayRow( - entry.label(), new BrowseNodeRow(entry.nodeType(), entry.label()), depth + (includeHeader ? 1 : 0))); - } - for (NodeMenuRegistry.Category child : NodeMenuRegistry.getChildCategories(category)) { - appendPaletteCategory(rows, child.id(), depth + 1, true); - } - } - - private List topPaletteCategories() { - return NodeMenuRegistry.getChildCategories(NodeMenuRegistry.ROOT).stream() - .filter(category -> submenuHasContent(category.id())) - .toList(); - } - - private int palettePanelTop() { return viewInset() + TOP_BAR_H; } - private int palettePanelBottom() { return gridBottom(); } - - private int visiblePaletteCategoryCount() { - return Math.max(1, (palettePanelBottom() - palettePanelTop() - 8) / (CATEGORY_BUTTON + 2)); - } - - private int maxPaletteCategoryScroll(List categories) { - return Math.max(0, categories.size() - visiblePaletteCategoryCount()); - } - - private BrowseNodeRow paletteNodeAt(double mouseX, double mouseY) { - if (openPaletteCategory == null || mouseX < viewInset() + CATEGORY_RAIL_W - || mouseX >= viewInset() + CATEGORY_RAIL_W + CATEGORY_PANEL_W) return null; - int listTop = palettePanelTop() + 24; - if (mouseY < listTop || mouseY >= palettePanelBottom()) return null; - List rows = paletteRows(); - int idx = paletteScroll + ((int) mouseY - listTop) / CATEGORY_ROW_H; - return idx >= 0 && idx < rows.size() ? rows.get(idx).node() : null; - } - - private void renderCategorySidebar(GuiGraphics graphics, int mx, int my) { - if (!categoryRailVisible) return; - int inset = viewInset(); - int top = palettePanelTop(); - int bottom = palettePanelBottom(); - graphics.fill(inset, top, inset + CATEGORY_RAIL_W, bottom, ComputedEditorTheme.BACKGROUND_SECONDARY); - graphics.vLine(inset + CATEGORY_RAIL_W - 1, top, bottom, ComputedEditorTheme.BORDER_DEFAULT); - List categories = topPaletteCategories(); - paletteCategoryScroll = Mth.clamp(paletteCategoryScroll, 0, maxPaletteCategoryScroll(categories)); - int by = top + 4; - int visibleCategories = visiblePaletteCategoryCount(); - for (int i = 0; i < visibleCategories && paletteCategoryScroll + i < categories.size(); i++) { - NodeMenuRegistry.Category category = categories.get(paletteCategoryScroll + i); - int bx = inset + (CATEGORY_RAIL_W - CATEGORY_BUTTON) / 2; - boolean selected = category.id().equals(openPaletteCategory); - boolean hovered = mx >= bx && mx < bx + CATEGORY_BUTTON && my >= by && my < by + CATEGORY_BUTTON; - ComputedEditorStyle.drawButton(graphics, bx, by, CATEGORY_BUTTON, CATEGORY_BUTTON, hovered, selected); - ComputedEditorIcons.drawCategory( - graphics, category.id(), bx + 5, by + 5, - selected ? ComputedEditorTheme.ACCENT : ComputedEditorTheme.TEXT_SECONDARY); - if (hovered) queueEditorTooltip(category.title(), mx, my); - by += CATEGORY_BUTTON + 2; - } - if (categories.size() > visibleCategories) { - int trackY = top + 4; - int trackH = Math.max(1, bottom - top - 8); - int thumbH = Math.max(10, trackH * visibleCategories / categories.size()); - int thumbY = trackY + (trackH - thumbH) * paletteCategoryScroll - / Math.max(1, categories.size() - visibleCategories); - ComputedEditorStyle.drawScrollbar( - graphics, inset + CATEGORY_RAIL_W - 4, trackY, 2, trackH, thumbY, thumbH, false); - } - if (openPaletteCategory == null) return; - - int x = inset + CATEGORY_RAIL_W; - ComputedEditorStyle.drawMenuPanel(graphics, x, top, CATEGORY_PANEL_W, bottom - top); - ComputedEditorStyle.drawField( - graphics, x + 5, top + 4, CATEGORY_PANEL_W - 10, 16, - paletteSearchFocused, mx >= x + 5 && mx < x + CATEGORY_PANEL_W - 5 && my >= top + 4 && my < top + 20); - String query = paletteSearch.isEmpty() ? "Search nodes..." : paletteSearch; - graphics.drawString(font, query, x + 9, top + 8, - paletteSearch.isEmpty() ? ComputedEditorTheme.TEXT_TERTIARY : ComputedEditorTheme.TEXT_PRIMARY, false); - - int listTop = top + 24; - int visible = Math.max(1, (bottom - listTop) / CATEGORY_ROW_H); - List rows = paletteRows(); - paletteScroll = Mth.clamp(paletteScroll, 0, Math.max(0, rows.size() - visible)); - BrowseNodeRow hoveredNode = null; - graphics.enableScissor(x + 1, listTop, x + CATEGORY_PANEL_W - 1, bottom - 1); - for (int i = 0; i < visible && paletteScroll + i < rows.size(); i++) { - PaletteDisplayRow row = rows.get(paletteScroll + i); - int absoluteIndex = paletteScroll + i; - int ry = listTop + i * CATEGORY_ROW_H; - boolean hovered = mx >= x && mx < x + CATEGORY_PANEL_W && my >= ry && my < ry + CATEGORY_ROW_H; - boolean keyboardSelected = paletteSearchFocused && absoluteIndex == paletteKeyboardIndex && row.node() != null; - if ((hovered || keyboardSelected) && row.node() != null) { - ComputedEditorStyle.drawMenuRow( - graphics, x + 1, ry, CATEGORY_PANEL_W - 2, CATEGORY_ROW_H, hovered, keyboardSelected); - hoveredNode = row.node(); - } - int color = row.node() == null ? ComputedEditorTheme.ACCENT_MUTED - : isEditorPeripheralLocked(row.node().nodeType()) - ? ComputedEditorTheme.STATUS_LOCKED_TEXT : ComputedEditorTheme.TEXT_PRIMARY; - String prefix = row.node() == null ? "" : "• "; - graphics.drawString(font, prefix + row.label().getString(), x + 7 + row.depth() * 8, ry + 5, color, false); - } - graphics.disableScissor(); - if (rows.size() > visible) { - int trackH = bottom - listTop - 4; - int thumbH = Math.max(10, trackH * visible / rows.size()); - int thumbY = listTop + 2 + (trackH - thumbH) * paletteScroll / Math.max(1, rows.size() - visible); - ComputedEditorStyle.drawScrollbar(graphics, x + CATEGORY_PANEL_W - 5, listTop + 2, 3, trackH, thumbY, thumbH, false); - } - if (hoveredNode != null) { - queueEditorTooltip( - NodeDescriptionCatalog.component(hoveredNode.nodeType(), hoveredNode.label()), mx, my); - } - if (pendingPaletteNode != null && paletteDragActivated) { - int w = Math.max(84, font.width(pendingPaletteNode.label()) + 14); - ComputedEditorStyle.drawMenuPanel(graphics, mx + 10, my + 8, w, 20); - graphics.drawString(font, pendingPaletteNode.label(), mx + 17, my + 14, ComputedEditorTheme.ACCENT, false); - } - } - - private boolean handleCategorySidebarClick(double mouseX, double mouseY, int button) { - if (!categoryRailVisible || button != 0) return false; - int inset = viewInset(); - int top = palettePanelTop(); - int bottom = palettePanelBottom(); - if (mouseX >= inset && mouseX < inset + CATEGORY_RAIL_W && mouseY >= top && mouseY < bottom) { - int by = top + 4; - List categories = topPaletteCategories(); - paletteCategoryScroll = Mth.clamp(paletteCategoryScroll, 0, maxPaletteCategoryScroll(categories)); - int visibleCategories = visiblePaletteCategoryCount(); - for (int i = 0; i < visibleCategories && paletteCategoryScroll + i < categories.size(); i++) { - NodeMenuRegistry.Category category = categories.get(paletteCategoryScroll + i); - int bx = inset + (CATEGORY_RAIL_W - CATEGORY_BUTTON) / 2; - if (mouseX >= bx && mouseX < bx + CATEGORY_BUTTON && mouseY >= by && mouseY < by + CATEGORY_BUTTON) { - openPaletteCategory = category.id().equals(openPaletteCategory) ? null : category.id(); - paletteScroll = 0; - paletteSearchFocused = false; - playUiClick(1.02f); - return true; - } - by += CATEGORY_BUTTON + 2; - } - return true; - } - if (openPaletteCategory == null) return false; - int x = inset + CATEGORY_RAIL_W; - if (mouseX < x || mouseX >= x + CATEGORY_PANEL_W || mouseY < top || mouseY >= bottom) return false; - if (mouseY < top + 22) { - paletteSearchFocused = true; - return true; - } - BrowseNodeRow node = paletteNodeAt(mouseX, mouseY); - if (node != null) { - if (isEditorPeripheralLocked(node.nodeType())) { playUiClick(0.82f); return true; } - pendingPaletteNode = node; - paletteDragStartX = (int) mouseX; - paletteDragStartY = (int) mouseY; - paletteDragActivated = false; - } - return true; - } - - private void movePaletteSelection(int direction) { - List rows = paletteRows(); - if (rows.isEmpty()) return; - int index = Mth.clamp(paletteKeyboardIndex, 0, rows.size() - 1); - for (int attempts = 0; attempts < rows.size(); attempts++) { - index = Mth.clamp(index + direction, 0, rows.size() - 1); - if (rows.get(index).node() != null) break; - if ((index == 0 && direction < 0) || (index == rows.size() - 1 && direction > 0)) break; - } - paletteKeyboardIndex = index; - int visible = Math.max(1, (palettePanelBottom() - (palettePanelTop() + 24)) / CATEGORY_ROW_H); - if (paletteKeyboardIndex < paletteScroll) paletteScroll = paletteKeyboardIndex; - else if (paletteKeyboardIndex >= paletteScroll + visible) paletteScroll = paletteKeyboardIndex - visible + 1; - } - - private void placePaletteKeyboardSelection() { - List rows = paletteRows(); - if (paletteKeyboardIndex < 0 || paletteKeyboardIndex >= rows.size()) return; - BrowseNodeRow row = rows.get(paletteKeyboardIndex).node(); - if (row == null || isEditorPeripheralLocked(row.nodeType())) return; - int canvasLeft = viewInset() + paletteWidth(); - int canvasRight = gridRight(); - int canvasTop = viewInset() + TOP_BAR_H; - int canvasBottom = gridBottom(); - WNode placed = addNodeAtReturning(row.nodeType(), - screenToGraphX((canvasLeft + canvasRight) / 2.0), - screenToGraphY((canvasTop + canvasBottom) / 2.0)); - if (placed != null) { - graph.getNodes().forEach(node -> node.setSelected(false)); - placed.setSelected(true); - selectedNode = placed; - } - } - - private void renderTopBar(GuiGraphics graphics, int mx, int my) { - int inset = viewInset(); - graphics.fill(inset, inset, width - inset, inset + TOP_BAR_H, ComputedEditorTheme.BACKGROUND_SECONDARY); - graphics.hLine(inset, width - inset, inset + TOP_BAR_H - 1, ComputedEditorTheme.BORDER_DEFAULT); - int menuX = categoryRailToggleX(); - int menuY = categoryRailToggleY(); - boolean menuHovered = categoryRailToggleContains(mx, my); - ComputedEditorStyle.drawButton( - graphics, menuX, menuY, TOP_BAR_MENU_BUTTON_W, topBarButtonHeight(), menuHovered, categoryRailVisible); - ComputedEditorIcons.drawMenu( - graphics, menuX + 3, menuY + (topBarButtonHeight() - 13) / 2, - categoryRailVisible ? ComputedEditorTheme.ACCENT : ComputedEditorTheme.TEXT_SECONDARY); - if (menuHovered) { - queueEditorTooltip(Component.literal(categoryRailVisible ? "Hide node categories" : "Show node categories"), mx, my); - } - int sx = sectionsToggleX(); - int sy = sectionsToggleY(); - boolean hovered = sectionsToggleContains(mx, my); - ComputedEditorStyle.drawButton( - graphics, sx, sy, SECTIONS_BUTTON_W, topBarButtonHeight(), - hovered, showSectionsSidebar); - ComputedEditorStyle.drawCenteredString( - graphics, font, "Sections", sx, sy, SECTIONS_BUTTON_W, - topBarButtonHeight(), - ComputedEditorTheme.TEXT_PRIMARY); - } - - private MenuRect currentContextBounds() { - int rows = contextKind == ContextKind.NODE ? 5 : 3; - int w = contextKind == ContextKind.NODE ? 132 : 142; - int h = rows * 18 + 4; - int x = Mth.clamp((int) graphToScreenX(contextAnchorGraphX), viewInset() + paletteWidth() + 2, - width - viewInset() - w - 2); - int y = Mth.clamp((int) graphToScreenY(contextAnchorGraphY), viewInset() + TOP_BAR_H + 2, - height - viewInset() - BOTTOM_BAR_H - h - 2); - return new MenuRect(x, y, w, h); - } - - private void renderContextMenu(GuiGraphics graphics, int mx, int my) { - if (contextKind == ContextKind.NONE) return; - MenuRect b = currentContextBounds(); - String[] rows = contextKind == ContextKind.NODE - ? new String[] {"Copy", "Duplicate", "Paste", "Disconnect", "Delete"} - : new String[] {"Search nodes / categories", "Paste", "New section"}; - ComputedEditorStyle.drawMenuPanel(graphics, b.x, b.y, b.w, b.h); - for (int i = 0; i < rows.length; i++) { - int ry = b.y + 2 + i * 18; - boolean hover = mx >= b.x && mx < b.x + b.w && my >= ry && my < ry + 18; - ComputedEditorStyle.drawMenuRow(graphics, b.x + 1, ry, b.w - 2, 18, hover, false); - int color = (contextKind == ContextKind.NODE && i == 4) - ? ComputedEditorTheme.STATUS_ERROR_TEXT : ComputedEditorTheme.TEXT_PRIMARY; - graphics.drawString(font, rows[i], b.x + 7, ry + 5, color, false); - } - } - - private void queueEditorTooltip(Component description, int mouseX, int mouseY) { - pendingEditorTooltip = description; - pendingEditorTooltipX = mouseX; - pendingEditorTooltipY = mouseY; - } - - private void renderPendingEditorTooltip(GuiGraphics graphics) { - if (pendingEditorTooltip == null) return; - int maximumTextWidth = Math.max(80, Math.min(220, width - menuEdgeMargin() * 2 - 12)); - List lines = font.split(pendingEditorTooltip, maximumTextWidth); - if (lines.isEmpty()) return; - int textWidth = 0; - for (net.minecraft.util.FormattedCharSequence line : lines) { - textWidth = Math.max(textWidth, font.width(line)); - } - int boxWidth = textWidth + 10; - int boxHeight = lines.size() * font.lineHeight + 8; - int x = pendingEditorTooltipX + 12; - if (x + boxWidth > menuEdgeRight()) x = pendingEditorTooltipX - boxWidth - 12; - x = Mth.clamp(x, menuEdgeLeft(), Math.max(menuEdgeLeft(), menuEdgeRight() - boxWidth)); - int y = pendingEditorTooltipY + 10; - if (y + boxHeight > menuEdgeBottom()) y = pendingEditorTooltipY - boxHeight - 10; - y = Mth.clamp(y, menuEdgeTop(), Math.max(menuEdgeTop(), menuEdgeBottom() - boxHeight)); - - // Commit every menu first, then submit the tooltip background and text in strict painter order. - graphics.flush(); - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 600); - ComputedEditorStyle.drawMenuPanel(graphics, x, y, boxWidth, boxHeight); - graphics.flush(); - int textY = y + 4; - for (net.minecraft.util.FormattedCharSequence line : lines) { - graphics.drawString(font, line, x + 5, textY, ComputedEditorTheme.TEXT_PRIMARY, false); - textY += font.lineHeight; - } - graphics.flush(); - graphics.pose().popPose(); - } - - private void closeTransientEditorChrome() { - shareMenuOpen = false; - contextKind = ContextKind.NONE; - paletteSearchFocused = false; - isSearching = false; - clearStickyBrowseRoot(); - clearPendingWireSpawn(); - } - - private static float easeOutCubic(float t) { - float u = 1.0f - t; - return 1.0f - u * u * u; - } - - @Override - public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - // drawManaged disables GuiGraphics's per-fill `bufferSource.endBatch()`, so the hundreds - // of fills/quads/strings emitted below batch into one OpenGL submission per render type - // instead of one submission per call. This is the dominant FPS win on busy editor screens. - graphics.drawManaged(() -> renderInner(graphics, mouseX, mouseY, partialTick)); - } - - private void renderInner(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - long nowNs = System.nanoTime(); - float deltaTime = (lastFrameTimeNs == 0) ? 0.016f : (float) ((nowNs - lastFrameTimeNs) / 1_000_000_000.0); - lastFrameTimeNs = nowNs; - if (cameraFocusActive) { - cameraFocusElapsedSec += deltaTime; - double t = cameraFocusDurationSec <= 0.0 ? 1.0 : Mth.clamp(cameraFocusElapsedSec / cameraFocusDurationSec, 0.0, 1.0); - // Time-parametric easing avoids frame-quantized feel. - double e = 1.0 - Math.pow(1.0 - t, 3.0); - panX = Mth.lerp(e, cameraFocusStartPanX, cameraFocusTargetPanX); - panY = Mth.lerp(e, cameraFocusStartPanY, cameraFocusTargetPanY); - if (t >= 1.0) { - cameraFocusActive = false; - } - } - updateEditorDetailLevel(); - EditorDetailLevel detailLevel = effectiveDetailLevel(); - if (detailLevel == EditorDetailLevel.FULL) { - wireController.advanceAnimation(deltaTime); - } - nodeLodRenderer.beginFrame(); - pendingEditorTooltip = null; - showLodInteractionHint = false; - - screenAnimation = Math.min(1.0f, screenAnimation + deltaTime / OPEN_DURATION_SEC); - float ease = easeOutCubic(screenAnimation); - this.mouseX = mouseX; - this.mouseY = mouseY; - refreshEditorDiagnostics(); - - int dimAlpha = (int) (160 * ease); - graphics.fill(0, 0, width, height, (dimAlpha << 24)); - - int inset = viewInset(); - int px1 = inset; - int py1 = inset; - int px2 = width - inset; - int py2 = height - inset; - int graphRight = px2 - GRID_RIGHT_PADDING; - int graphBottom = py2 - GRID_BOTTOM_PADDING; - int panelBg = ((int) (230 * ease) << 24) - | (ComputedEditorTheme.BACKGROUND_PRIMARY & 0x00FFFFFF); - ComputedEditorStyle.drawBeveledPanel( - graphics, - px1, - py1, - px2 - px1, - py2 - py1, - panelBg, - ComputedEditorTheme.BORDER_MENU, - ComputedEditorTheme.BORDER_INNER); - if (isEditingNestedFunction()) { - String fnTitle = "Function"; - if (functionStore != null && !functionEditStack.isEmpty()) { - FunctionCardNode host = functionEditStack.peek().openedHost(); - FunctionDefinitionStore.Definition def = functionStore.get(host.getFunctionId()); - if (def != null && def.name() != null && !def.name().isBlank()) { - fnTitle = def.name(); - } - } - String lead = fnTitle + " - "; - String tail = "Go back"; - int keyDraw = 11; - int gap = 4; - int rowTop = py1 + 4; - int textY = rowTop + (keyDraw - font.lineHeight) / 2 + 1; - int totalW = font.width(lead) + keyDraw + gap + font.width(tail); - int startX = px1 + (px2 - px1 - totalW) / 2; - graphics.drawString(font, lead, startX, textY, ComputedEditorTheme.ACCENT_MUTED, false); - int ix = startX + font.width(lead); - blitScaledHintTile(graphics, KEY_CAP_ESC, ix, rowTop, keyDraw); - graphics.drawString(font, tail, ix + keyDraw + gap, textY, ComputedEditorTheme.ACCENT_MUTED, false); - } - - for (int i = py1; i < py2; i += 2) { - graphics.fill(px1, i, px2, i + 1, 0x0A000000); - } - - graphics.enableScissor(px1, py1, graphRight, graphBottom); - - graphics.pose().pushPose(); - float sOut = editorContentScale(); - graphics.pose().translate(width / 2f, height / 2f, 0); - graphics.pose().scale(sOut, sOut, 1.0f); - graphics.pose().translate(-width / 2f, -height / 2f, 0); - - drawGrid(graphics); - - graphics.pose().translate(panX, panY, 0); - - for (WGraph.WSection s : sectionsSortedByLayer(graph.getSections())) { - boolean renaming = s.getId().equals(renamingSectionId); - boolean secSel = s.getId().equals(selectedSectionId); - int bg = s.getBodyColorArgb(); - graphics.fill(s.getX(), s.getY(), s.getX() + s.getWidth(), s.getY() + s.getHeight(), bg); - int head = sectionHeaderArgb(s, renaming, secSel); - graphics.fill(s.getX(), s.getY(), s.getX() + s.getWidth(), s.getY() + 16, head); - int lx = s.getX() + 4; - int ly = s.getY() + 4; - if (renaming) { - drawSectionRenameTextWithSelectionAndCaret(graphics, lx, ly, s.getX() + s.getWidth() - 4); - } else { - graphics.drawString(font, s.getName(), lx, ly, ComputedEditorTheme.TEXT_PRIMARY, false); - } - if (s.getId().equals(selectedSectionId)) { - drawSectionResizeHandles(graphics, s); - } - } - - int gmx = screenToGraphX(mouseX); - int gmy = screenToGraphY(mouseY); - if (detailLevel == EditorDetailLevel.FULL - && !isSearching - && linkingNode == null - && draggingWireConnIdx < 0 - && !isCreatingSection - && isInsideEditorPanel(mouseX, mouseY)) { - updateWireInteractionHover(gmx, gmy); - } else { - wireController.clearHover(); - wireController.invalidateHoverCache(); - } - GraphRect wireViewport = graphViewport( - px1, py1, graphRight, graphBottom, 36.0 / Math.max(0.1f, editorContentScale())); - wireController.render( - graphics, - graph, - wireViewport, - editorContentScale(), - editorRevision, - wireGeometryMoving(), - detailLevel); - - if (linkingNode != null) { - int sx = linkingNode.getX() + linkingNode.getWidth(); - int sy = linkingNode.getY() + 18 + linkingPin * 12; - int tx = screenToGraphX(mouseX); - int ty = screenToGraphY(mouseY); - wireController.renderCurve(graphics, sx, sy, tx, ty, 0xAAFFFFFF, 1.5f); - } else if (pendingWireFromNode != null && isSearching && pendingWireDragFrozen) { - int sx = pendingWireFromNode.getX() + pendingWireFromNode.getWidth(); - int sy = pendingWireFromNode.getY() + 18 + pendingWireFromOutputPin * 12; - wireController.renderCurve( - graphics, sx, sy, pendingWireFrozenTx, pendingWireFrozenTy, 0xAAFFFFFF, 1.5f); - } - - List drawNodes = visibleNodes(px1, py1, graphRight, graphBottom); - sortNodesForDrawOrder(drawNodes); - int z = 0; - for (WNode node : drawNodes) { - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, z++ * 10); - if (detailLevel == EditorDetailLevel.FULL) { - node.render(graphics, screenToGraphX(mouseX), screenToGraphY(mouseY), partialTick); - if (node instanceof FunctionCardNode fc) { - if (innerGraphHasLockedPeripheral(fc.getInnerGraph())) { - drawEditorPeripheralLockOverlay(graphics, node); - } - } else if (isEditorPeripheralLocked(node.getTypeId())) { - drawEditorPeripheralLockOverlay(graphics, node); - } - } else { - node.ensureLayoutUpToDate(); - NodeLodRenderer.VisualState visualState = nodeLodVisualState(node, gmx, gmy); - showLodInteractionHint |= visualState.hovered(); - nodeLodRenderer.renderNode( - graphics, - font, - node, - visualState, - detailLevel, - editorContentScale(), - Mth.floor(graphToScreenX(node.getX())), - Mth.floor(graphToScreenY(node.getY())), - Mth.ceil(graphToScreenX(node.getX() + node.getWidth())), - Mth.ceil(graphToScreenY(node.getY() + node.getHeight())), - z); - } - updateIndexedNode(node); - graphics.pose().popPose(); - } - - if (detailLevel == EditorDetailLevel.FULL) { - renderParticles(graphics, deltaTime); - } - - graphics.pose().popPose(); - - renderScreenSpaceGraphOutlines(graphics, drawNodes, detailLevel, gmx, gmy); - renderScreenSpaceDragRectangles(graphics); - - if (detailLevel != EditorDetailLevel.FULL) { - graphics.pose().pushPose(); - // Node bodies receive an increasing graph-space Z value for deterministic overlap. - // The screen-space label pass must sit above the highest visible body, not reset to Z=0. - graphics.pose().translate(0, 0, Math.max(3000, z * 10 + 100)); - nodeLodRenderer.renderLabels(graphics); - graphics.pose().popPose(); - } - - // Commit graph-space bodies and LOD labels before screen-space menus. This is an explicit - // painter-order boundary between graph render types and editor chrome. - graphics.flush(); - - graphics.disableScissor(); - - // The final graph pixel aligns with the top-bar controls; the remaining two pixels are - // deliberate breathing room inside the outer beveled panel. - graphics.vLine(graphRight - 1, py1 + TOP_BAR_H, graphBottom - 1, ComputedEditorTheme.BORDER_DEFAULT); - graphics.hLine(px1, graphRight - 1, graphBottom - 1, ComputedEditorTheme.BORDER_DEFAULT); - - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 2500); - - renderNodeActionDock(graphics, mouseX, mouseY, ease); - renderLodInteractionHint(graphics, detailLevel); - - renderSectionsSidebar(graphics, mouseX, mouseY, ease); - renderDiagnosticsPanel(graphics, mouseX, mouseY, ease); - renderTopBar(graphics, mouseX, mouseY); - renderCategorySidebar(graphics, mouseX, mouseY); - renderSchematicToolbar(graphics, mouseX, mouseY, ease); - - if (isSearching) { - rebuildSearchHitRows(); - if (searchQuery.trim().isEmpty()) { - layoutBrowseMenuForPointer(mouseX, mouseY); - } else { - menuFlyoutPath.clear(); - stickyBrowseRootId = null; - clampSearchMenuOnScreen(); - } - renderSearchMenu(graphics); - } - - renderSectionColorPickerOverlay(graphics); - renderContextMenu(graphics, mouseX, mouseY); - renderPendingEditorTooltip(graphics); - - graphics.pose().popPose(); - - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 5000); - renderItemPickerOverlay(graphics); - graphics.pose().popPose(); - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 9000); - renderExportDialog(graphics); - renderImportFromStringDialog(graphics); - graphics.pose().popPose(); - - if (newFunctionNamingOpen) { - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 5200); - renderNewFunctionNamingOverlay(graphics); - graphics.pose().popPose(); - } - } - - private static final int DIAGNOSTICS_INDICATOR_W = 128; - private static final int DIAGNOSTICS_INDICATOR_H = 22; - private static final int DIAGNOSTICS_MAX_ROWS = 6; - - private int diagnosticsIndicatorX() { - return viewInset() + FULLSCREEN_BTN_PAD; - } - - private int diagnosticsIndicatorY() { - return height - viewInset() - BOTTOM_BAR_H - DIAGNOSTICS_INDICATOR_H - FULLSCREEN_BTN_PAD; - } - - private boolean diagnosticsIndicatorContains(double mouseX, double mouseY) { - if (diagnosticsController.diagnostics().isEmpty()) { - return false; - } - int x = diagnosticsIndicatorX(); - int y = diagnosticsIndicatorY(); - return mouseX >= x - && mouseX < x + DIAGNOSTICS_INDICATOR_W - && mouseY >= y - && mouseY < y + DIAGNOSTICS_INDICATOR_H; - } - - private MenuRect diagnosticsPanelBounds() { - int width = Math.min(380, Math.max(120, this.width - viewInset() * 2 - FULLSCREEN_BTN_PAD * 2)); - int rows = Math.min(DIAGNOSTICS_MAX_ROWS, diagnosticsController.diagnostics().size()); - int height = 18 + rows * 14; - int x = diagnosticsIndicatorX(); - int y = Math.max(viewInset() + 4, diagnosticsIndicatorY() - height - 4); - return new MenuRect(x, y, width, height); - } - - private boolean diagnosticsPanelContains(double mouseX, double mouseY) { - if (!diagnosticsPanelOpen || diagnosticsController.diagnostics().isEmpty()) { - return false; - } - return diagnosticsPanelBounds().contains((int) mouseX, (int) mouseY); - } - - private void renderDiagnosticsPanel(GuiGraphics graphics, int mouseX, int mouseY, float ease) { - if (diagnosticsController.diagnostics().isEmpty()) { - return; - } - int x = diagnosticsIndicatorX(); - int y = diagnosticsIndicatorY(); - boolean hovered = diagnosticsIndicatorContains(mouseX, mouseY); - int accent = diagnosticsController.diagnostics().hasErrors() ? 0xFFFF6B6B : 0xFFFFC766; - int fill = ((int) (220 * ease) << 24) | (hovered ? 0x3A2A2A : 0x241C1C); - graphics.fill(x, y, x + DIAGNOSTICS_INDICATOR_W, y + DIAGNOSTICS_INDICATOR_H, fill); - graphics.renderOutline(x, y, DIAGNOSTICS_INDICATOR_W, DIAGNOSTICS_INDICATOR_H, accent); - String label = "! Diagnostics: " + diagnosticsController.diagnostics().size(); - graphics.drawString( - font, - label, - x + 7, - y + (DIAGNOSTICS_INDICATOR_H - font.lineHeight) / 2 + 1, - accent, - false); - - if (!diagnosticsPanelOpen) { - return; - } - MenuRect panel = diagnosticsPanelBounds(); - ComputedEditorStyle.drawMenuPanel(graphics, panel.x(), panel.y(), panel.w(), panel.h()); - graphics.drawString( - font, - "Graph diagnostics", - panel.x() + 6, - panel.y() + 5, - ComputedEditorTheme.TEXT_HEADER, - false); - int rowY = panel.y() + 18; - int shown = Math.min(DIAGNOSTICS_MAX_ROWS, diagnosticsController.diagnostics().size()); - for (int index = 0; index < shown; index++) { - EditorDiagnostic diagnostic = diagnosticsController.diagnostics().all().get(index); - int color = diagnostic.severity() == EditorDiagnostic.Severity.ERROR - ? ComputedEditorTheme.STATUS_ERROR_TEXT - : ComputedEditorTheme.STATUS_WARNING_TEXT; - String target = diagnostic.target().kind() == DiagnosticTarget.Kind.EDITOR - ? "editor" - : diagnostic.target().kind().name().toLowerCase(java.util.Locale.ROOT) - + " " - + abbreviateDiagnosticKey(diagnostic.target().key()); - String line = "[" + target + "] " + diagnostic.message(); - line = font.plainSubstrByWidth(line, panel.w() - 14); - graphics.drawString(font, line, panel.x() + 7, rowY + 2, color, false); - rowY += 14; - } - } - - private static String abbreviateDiagnosticKey(String key) { - return key.length() <= 8 ? key : key.substring(0, 8); - } - - private void renderScreenSpaceGraphOutlines( - GuiGraphics graphics, List drawNodes, EditorDetailLevel detailLevel, int graphMouseX, int graphMouseY) { - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 2000); - for (WGraph.WSection section : graph.getSections()) { - int left = Mth.floor(graphToScreenX(section.getX())); - int top = Mth.floor(graphToScreenY(section.getY())); - int right = Mth.ceil(graphToScreenX(section.getX() + section.getWidth())); - int bottom = Mth.ceil(graphToScreenY(section.getY() + section.getHeight())); - int color = section.getId().equals(selectedSectionId) - ? ComputedEditorTheme.ACCENT : ComputedEditorTheme.ACCENT_DARK; - graphics.renderOutline(left, top, Math.max(1, right - left), Math.max(1, bottom - top), color); - } - if (detailLevel == EditorDetailLevel.FULL) { - for (WNode node : drawNodes) { - NodeLodRenderer.VisualState state = nodeLodVisualState(node, graphMouseX, graphMouseY); - int color; - if (state.diagnosticError() || state.diagnosticWarning() || state.peripheralLocked() - || node == draggingNode || state.selected()) { - color = ComputedEditorTheme.nodeOutline( - state.selected(), node == draggingNode, state.diagnosticError(), - state.diagnosticWarning(), state.peripheralLocked()); - } else { - color = state.hovered() ? ComputedEditorTheme.BORDER_HIGHLIGHT : ComputedEditorTheme.ACCENT; - } - int left = Mth.floor(graphToScreenX(node.getX())); - int top = Mth.floor(graphToScreenY(node.getY())); - int right = Mth.ceil(graphToScreenX(node.getX() + node.getWidth())); - int bottom = Mth.ceil(graphToScreenY(node.getY() + node.getHeight())); - graphics.renderOutline(left, top, Math.max(1, right - left), Math.max(1, bottom - top), color); - } - } - graphics.pose().popPose(); - } - - private void renderScreenSpaceDragRectangles(GuiGraphics graphics) { - if (!isSelecting && !isCreatingSection) return; - double graphLeft = isSelecting - ? Math.min(selStartX, selEndX) : Math.min(sectionCreateStartX, sectionCreateEndX); - double graphTop = isSelecting - ? Math.min(selStartY, selEndY) : Math.min(sectionCreateStartY, sectionCreateEndY); - double graphRight = isSelecting - ? Math.max(selStartX, selEndX) : Math.max(sectionCreateStartX, sectionCreateEndX); - double graphBottom = isSelecting - ? Math.max(selStartY, selEndY) : Math.max(sectionCreateStartY, sectionCreateEndY); - int left = Mth.floor(graphToScreenX(graphLeft)); - int top = Mth.floor(graphToScreenY(graphTop)); - int right = Mth.ceil(graphToScreenX(graphRight)); - int bottom = Mth.ceil(graphToScreenY(graphBottom)); - int fill = isSelecting ? 0x3300FF88 : 0x332D66FF; - int border = isSelecting ? 0xFF00FF88 : 0xFF74A0FF; - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 2200); - graphics.fill(left, top, right, bottom, fill); - graphics.renderOutline(left, top, Math.max(1, right - left), Math.max(1, bottom - top), border); - graphics.pose().popPose(); - } - - private NodeLodRenderer.VisualState nodeLodVisualState(WNode node, int graphMouseX, int graphMouseY) { - List diagnostics = - diagnosticsController.diagnostics().forTarget(DiagnosticTarget.node(node.getId())); - boolean error = diagnostics.stream() - .anyMatch(diagnostic -> diagnostic.severity() == EditorDiagnostic.Severity.ERROR); - boolean warning = !error && !diagnostics.isEmpty(); - boolean locked = node instanceof FunctionCardNode fc - ? innerGraphHasLockedPeripheral(fc.getInnerGraph()) - : isEditorPeripheralLocked(node.getTypeId()); - boolean hovered = graphMouseX >= node.getX() - && graphMouseX <= node.getX() + node.getWidth() - && graphMouseY >= node.getY() - && graphMouseY <= node.getY() + node.getHeight(); - return new NodeLodRenderer.VisualState(hovered, node.isSelected(), error, warning, locked); - } - - private void renderLodInteractionHint(GuiGraphics graphics, EditorDetailLevel detailLevel) { - if (detailLevel == EditorDetailLevel.FULL || !showLodInteractionHint) { - return; - } - String text = Component.translatable("gui.computed.lod.zoom_in").getString(); - int textWidth = font.width(text); - int x = Mth.clamp(this.mouseX + 12, viewInset() + 4, width - viewInset() - textWidth - 10); - int y = Mth.clamp(this.mouseY + 12, viewInset() + 4, height - viewInset() - font.lineHeight - 8); - ComputedEditorStyle.drawMenuPanel( - graphics, x - 4, y - 3, textWidth + 8, font.lineHeight + 6); - graphics.drawString(font, text, x, y, ComputedEditorTheme.TEXT_PRIMARY, false); - } - - private void renderNewFunctionNamingOverlay(GuiGraphics graphics) { - if (!newFunctionNamingOpen || functionStore == null) { - return; - } - graphics.fill(0, 0, width, height, ComputedEditorTheme.BACKGROUND_MODAL_SCRIM); - int boxW = 300; - int boxH = 76; - int bx = width / 2 - boxW / 2; - int by = height / 3; - drawMenuPanel(graphics, bx, by, boxW, boxH); - graphics.drawString(font, "Name new function", bx + 8, by + 8, ComputedEditorTheme.ACCENT_MUTED, false); - String show = newFunctionNameBuffer.isEmpty() ? "_" : newFunctionNameBuffer + "_"; - graphics.drawString(font, show, bx + 8, by + 30, ComputedEditorTheme.TEXT_HEADER, false); - graphics.drawString( - font, - "Enter: create Esc: cancel", - bx + 8, - by + boxH - 18, - ComputedEditorTheme.TEXT_SECONDARY, - false); - } - - private void renderSectionsSidebar(GuiGraphics graphics, int mx, int my, float ease) { - if (!showSectionsSidebar) { - return; - } - int x = sectionsSidebarX(); - int y = sectionsSidebarY(); - int w = sectionsSidebarW(); - int h = sectionsSidebarH(); - ComputedEditorStyle.drawBeveledPanel(graphics, x, y, w, h); - graphics.drawString(font, "Sections", x + 6, y + 4, ComputedEditorTheme.ACCENT_MUTED, false); - int ry = y + 18; - for (WGraph.WSection s : sectionsSortedByLayer(graph.getSections())) { - if (ry + 14 > y + h - 4) { - break; - } - boolean renaming = s.getId().equals(renamingSectionId); - boolean selected = s.getId().equals(selectedSectionId); - if (renaming) { - ComputedEditorStyle.drawMenuRow(graphics, x, ry - 1, w, 13, false, true); - } else if (selected) { - ComputedEditorStyle.drawMenuRow(graphics, x, ry - 1, w, 13, false, true); - } - int lx = x + 6; - if (renaming) { - drawSectionRenameTextWithSelectionAndCaret(graphics, lx, ry, x + w - 6); - } else { - int textColor = selected ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.TEXT_SECONDARY; - graphics.drawString(font, s.getName(), lx, ry, textColor, false); - } - ry += 13; - } - } - - private int schematicBtnX() { - return categoryRailToggleX() + TOP_BAR_MENU_BUTTON_W + TOP_BAR_BUTTON_GAP; - } - - private int schematicBtnY() { - return viewInset() + TOP_BAR_PADDING; - } - - private boolean schematicBtnContains(double mx, double my) { - if (functionStore == null) { - return false; - } - int x = schematicBtnX(); - int y = schematicBtnY(); - return mx >= x && mx < x + FUNCTIONS_BUTTON_W - && my >= y && my < y + topBarButtonHeight(); - } - - private void postShareStatus(boolean error, String key, Object... args) { - importDialogStatusError = error; - importDialogStatus = Component.translatable(key, args).getString(); - if (minecraft.player != null) { - minecraft.player.displayClientMessage(Component.translatable(key, args), true); - } - } - - private void openExportDialog() { - try { - if (functionStore != null) { - functionStore.syncBodiesFromGraph(graph); - } - String out = ComputedGraphShareCodec.encode(graph, functionStore); - exportDialogText = out; - exportDialogOpen = true; - importDialogOpen = false; - postShareStatus(false, "gui.computed.share.export_ready", out.length()); - playUiClick(1.05f); - } catch (Exception e) { - postShareStatus(true, "gui.computed.share.export_failed"); - playUiClick(0.82f); - } - } - - private void openImportFromStringDialog() { - importDialogOpen = true; - exportDialogOpen = false; - importDialogText = ""; - importDialogStatus = ""; - importDialogStatusError = false; - } - - private void closeImportFromStringDialog() { - importDialogOpen = false; - } - - private void closeExportDialog() { - exportDialogOpen = false; - } - - private void importGraphFromShareString(String input) { - try { - ComputedGraphShareCodec.Decoded decoded = ComputedGraphShareCodec.decode(input); - recordCheckpointBeforeEdit(); - graph.load(decoded.graph()); - if (functionStore != null) { - functionStore.load(decoded.functions()); - FunctionCardNode.applyLibraryToInnerGraphs(graph, functionStore); - } - postShareStatus( - false, - decoded.legacy() - ? "gui.computed.share.import_success_legacy" - : "gui.computed.share.import_success", - decoded.embeddedCustomNodeCount()); - closeImportFromStringDialog(); - playUiClick(1.07f); - } catch (Exception e) { - postShareStatus(true, "gui.computed.share.import_failed"); - playUiClick(0.82f); - } - } - - private int schematicPickerW() { - return Math.max(Math.max(menuMinColWidth(), 140), FUNCTION_LIB_PANEL_W); - } - - /** - * Padding + title + “New” + fixed-height definitions viewport + open-folder + import (matches - * {@link #renderSchematicToolbar}). - */ - private int schematicPickerH() { - if (functionStore == null) { - return 0; - } - int rh = menuRowHeight(); - return 12 - + functionPickerPlacedSectionHeight() - + FUNCTION_LIB_TITLE_H - + rh - + FUNCTION_LIB_VISIBLE_ROWS * FUNCTION_LIB_NAME_ROW_H - + rh - + rh - + LIBRARY_HINT_BLOCK_H; - } - - private int functionPickerDefsStartY(int panelTop) { - return functionPickerNewRowY(panelTop) + menuRowHeight(); - } - - private int functionPickerFolderRowY(int panelTop) { - return functionPickerDefsStartY(panelTop) + FUNCTION_LIB_VISIBLE_ROWS * FUNCTION_LIB_NAME_ROW_H; - } - - private int functionPickerDefsViewportHeight() { - return FUNCTION_LIB_VISIBLE_ROWS * FUNCTION_LIB_NAME_ROW_H; - } - - private boolean functionPickerDefsViewportContains(double mx, double my) { - if (!functionPickerOpen || functionStore == null) { - return false; - } - int px = schematicPickerX(); - int py = schematicPickerY(); - int pw = schematicPickerW(); - int defsTop = functionPickerDefsStartY(py); - int vh = functionPickerDefsViewportHeight(); - return mx >= px && mx < px + pw && my >= defsTop && my < defsTop + vh; - } - - /** Computes flyout bounds (must match {@link #renderFunctionImportFlyout}). */ - private void layoutFunctionImportFlyout(int[] outXYWH) { - int px = schematicPickerX(); - int py = schematicPickerY(); - int pw = schematicPickerW(); - int impY = functionPickerImportRowY(py); - int nf = functionDiscImportFiles.size(); - int rowH = FUNCTION_LIB_NAME_ROW_H; - int vis = FUNCTION_LIB_VISIBLE_ROWS; - int fh; - if (nf == 0) { - fh = 6 + menuRowHeight() + 6; - } else { - fh = 6 + vis * rowH + 6 + (nf > vis ? 12 : 0); - } - int fw = Math.max(pw, 172); - int fx = px + pw + 3; - int fy = impY; - int er = menuEdgeRight(); - if (fx + fw > er) { - fx = px - fw - 3; - } - int eb = menuEdgeBottom(); - if (fy + fh > eb) { - fy = eb - fh; - } - int et = menuEdgeTop(); - if (fy < et) { - fy = et; - } - outXYWH[0] = fx; - outXYWH[1] = fy; - outXYWH[2] = fw; - outXYWH[3] = fh; - } - - private boolean functionImportFlyoutContains(double mx, double my) { - if (!functionImportSubmenuOpen || clientNestedFunctionsDirectory() == null) { - return false; - } - int[] b = new int[4]; - layoutFunctionImportFlyout(b); - return mx >= b[0] && mx < b[0] + b[2] && my >= b[1] && my < b[1] + b[3]; - } - - private int functionPickerImportRowY(int panelTop) { - return functionPickerFolderRowY(panelTop) + menuRowHeight(); - } - - private int functionPickerNewRowY(int panelTop) { - return panelTop + 6 + functionPickerPlacedSectionHeight() + FUNCTION_LIB_TITLE_H; - } - - private int schematicPickerX() { - int px = schematicBtnX(); - int pw = schematicPickerW(); - int el = menuEdgeLeft(); - int er = menuEdgeRight(); - if (px + pw > er) { - px = er - pw; - } - return Math.max(px, el); - } - - private int schematicPickerY() { - int py = viewInset() + TOP_BAR_H + 2; - int ph = schematicPickerH(); - int et = menuEdgeTop(); - int eb = menuEdgeBottom(); - if (py + ph > eb) { - py = eb - ph; - } - return Math.max(py, et); - } - - private boolean functionPickerPanelContains(double mx, double my) { - if (!functionPickerOpen || functionStore == null) { - return false; - } - int px = schematicPickerX(); - int py = schematicPickerY(); - int pw = schematicPickerW(); - int ph = schematicPickerH(); - return mx >= px && mx < px + pw && my >= py && my < py + ph; - } - - private void drawLibraryFnRenameTextWithSelectionAndCaret( - GuiGraphics graphics, int lx, int ly, int maxTextRight) { - int selStart = Math.min(libraryFnRenameCursor, libraryFnRenameSelectionPos); - int selEnd = Math.max(libraryFnRenameCursor, libraryFnRenameSelectionPos); - if (selStart != selEnd) { - int left = lx + font.width(libraryFnRenameBuffer.substring(0, selStart)); - int right = lx + font.width(libraryFnRenameBuffer.substring(0, selEnd)); - left = Math.min(left, maxTextRight); - right = Math.min(right, maxTextRight); - if (left < right) { - graphics.fill(left, ly, right, ly + Math.max(8, font.lineHeight), 0x664A90FF); - } - } - graphics.drawString(font, libraryFnRenameBuffer, lx, ly, 0xFFEAF0FF, false); - drawSectionRenameCaret( - graphics, - lx, - ly, - maxTextRight, - libraryFnRenameBuffer.substring(0, libraryFnRenameCursor)); - } - - private void refreshFunctionDiscFileList() { - functionDiscImportFiles.clear(); - Path root = clientNestedFunctionsDirectory(); - if (root == null || !Files.isDirectory(root)) { - return; - } - try (java.util.stream.Stream stream = Files.list(root)) { - stream.filter(p -> p.getFileName().toString().endsWith(".nbt")) - .sorted(java.util.Comparator.comparing(p -> p.getFileName().toString().toLowerCase())) - .forEach(functionDiscImportFiles::add); - } catch (IOException ignored) { - } - } - - /** Adds a new entry to {@link #functionStore} from the {@code .nbt} at {@code fileIndex} (full inner graph tag). */ - private void importDiscFileAtIndex(int fileIndex) { - Path root = clientNestedFunctionsDirectory(); - if (root == null || functionDiscImportFiles.isEmpty()) { - playUiClick(0.9f); - return; - } - int n = functionDiscImportFiles.size(); - int idx = Mth.clamp(fileIndex, 0, n - 1); - Path file = functionDiscImportFiles.get(idx); - try { - CompoundTag tag = NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap()); - CompoundTag importedGraph = tag.contains("nodes", Tag.TAG_LIST) - ? tag.copy() - : ProgramBridge.decode(tag).graph().save(); - String base = file.getFileName().toString(); - if (base.endsWith(".nbt")) { - base = base.substring(0, base.length() - 4); - } - if (base.isEmpty()) { - base = "imported"; - } - recordCheckpointBeforeEdit(); - functionStore.addNew(base, importedGraph); - playUiClick(1.08f); - } catch (IOException | RuntimeException e) { - playUiClick(0.85f); - } - } - - private void renderFunctionImportFlyout(GuiGraphics graphics, int mx, int my) { - if (!functionImportSubmenuOpen || clientNestedFunctionsDirectory() == null) { - return; - } - int nf = functionDiscImportFiles.size(); - functionDiscImportListScroll = - Mth.clamp(functionDiscImportListScroll, 0, Math.max(0, nf - FUNCTION_LIB_VISIBLE_ROWS)); - int[] b = new int[4]; - layoutFunctionImportFlyout(b); - int fx = b[0]; - int fy = b[1]; - int fw = b[2]; - int fh = b[3]; - drawMenuPanel(graphics, fx, fy, fw, fh); - int rowH = FUNCTION_LIB_NAME_ROW_H; - int vis = FUNCTION_LIB_VISIBLE_ROWS; - int contentTop = fy + 6; - if (nf == 0) { - graphics.drawString(font, "(no .nbt)", fx + 6, contentTop + 2, ComputedEditorTheme.TEXT_TERTIARY, false); - return; - } - int footerGap = nf > vis ? 12 : 0; - int listClipBottom = contentTop + vis * rowH; - int flyListRight = fx + fw - 2 - SCROLLER_TRACK_W; - graphics.enableScissor(fx + 1, contentTop, flyListRight, listClipBottom); - int show = Math.min(vis, Math.max(0, nf - functionDiscImportListScroll)); - for (int j = 0; j < show; j++) { - Path fp = functionDiscImportFiles.get(functionDiscImportListScroll + j); - int rowY = contentTop + j * rowH; - boolean hr = mx >= fx && mx < fx + fw && my >= rowY && my < rowY + rowH; - if (hr) { - ComputedEditorStyle.drawMenuRow( - graphics, fx, rowY, flyListRight - fx, rowH, true, false); - } - String name = fp.getFileName().toString(); - int mw = flyListRight - fx - 10; - if (font.width(name) > mw) { - while (name.length() > 2 && font.width(name + "…") > mw) { - name = name.substring(0, name.length() - 1); - } - name = name + "…"; - } - graphics.drawString(font, name, fx + 6, rowY + 1, ComputedEditorTheme.TEXT_PRIMARY, false); - } - graphics.disableScissor(); - drawInsetVerticalScroller( - graphics, - fx + fw - 2 - SCROLLER_TRACK_W, - contentTop, - vis * rowH, - functionDiscImportListScroll, - nf, - vis); - if (footerGap > 0) { - int from = functionDiscImportListScroll + 1; - int to = functionDiscImportListScroll + show; - String footer = from + "–" + to + " / " + nf; - graphics.drawString(font, footer, fx + 6, fy + fh - 11, ComputedEditorTheme.TEXT_SECONDARY, false); - } - } - - private int functionIconColumnX(int panelX) { - return panelX + 8; - } - - private int functionTextColumnX(int panelX) { - return functionIconColumnX(panelX) + FUNCTION_ICON_COLUMN_W + 6; - } - - private void renderFunctionImportRow( - GuiGraphics graphics, int mx, int my, int px, int rowY, int pw, int rowH) { - boolean hovered = mx >= px && mx < px + pw && my >= rowY && my < rowY + rowH; - int fileCount = functionDiscImportFiles.size(); - boolean enabled = clientNestedFunctionsDirectory() != null; - if ((hovered || functionImportSubmenuOpen) && enabled) { - ComputedEditorStyle.drawMenuRow( - graphics, px, rowY, pw, rowH, hovered, functionImportSubmenuOpen); - } - int iconX = functionIconColumnX(px) + (FUNCTION_ICON_COLUMN_W - ICON_SIZE) / 2; - int iconY = rowY + (rowH - ICON_SIZE) / 2; - float tint = enabled ? 1.0f : 0.35f; - ComputedEditorStyle.beginTextureIcon(graphics); - graphics.setColor(tint, tint, tint, 1.0f); - graphics.blit(ICON_UPLOAD, iconX, iconY, 0, 0, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE); - graphics.setColor(1.0f, 1.0f, 1.0f, 1.0f); - int textY = rowY + (rowH - font.lineHeight) / 2 + 1; - graphics.drawString( - font, - "Import…", - functionTextColumnX(px), - textY, - enabled ? ComputedEditorTheme.ACCENT_MUTED : ComputedEditorTheme.TEXT_DISABLED, - false); - if (enabled && fileCount > 0) { - ComputedEditorIcons.drawChevron( - graphics, px + pw - 12, rowY + (rowH - 12) / 2, - ComputedEditorTheme.TEXT_SECONDARY); - } - } - - private void renderSchematicToolbar(GuiGraphics graphics, int mx, int my, float ease) { - if (functionStore == null) { - return; - } - int tx = schematicBtnX(); - int ty = schematicBtnY(); - boolean hov = schematicBtnContains(mx, my); - int buttonHeight = topBarButtonHeight(); - ComputedEditorStyle.drawButton( - graphics, tx, ty, FUNCTIONS_BUTTON_W, buttonHeight, hov, functionPickerOpen); - int six = tx + 4; - int siy = ty + (buttonHeight - ICON_SIZE) / 2; - ComputedEditorStyle.beginTextureIcon(graphics); - graphics.blit(ICON_SCHEMATIC, six, siy, 0, 0, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE); - graphics.drawString( - font, - "Functions", - six + ICON_SIZE + 4, - ty + (buttonHeight - font.lineHeight) / 2 + 1, - ComputedEditorTheme.TEXT_PRIMARY, - false); - - if (functionPickerOpen) { - int px = schematicPickerX(); - int py = schematicPickerY(); - int pw = schematicPickerW(); - int ph = schematicPickerH(); - drawMenuPanel(graphics, px, py, pw, ph); - int rh = menuRowHeight(); - int placedH = functionPickerPlacedSectionHeight(); - graphics.fill( - px + 2, - py + 2, - px + pw - 2, - py + 6 + placedH + FUNCTION_LIB_TITLE_H, - ComputedEditorTheme.BACKGROUND_SECTION); - int contentY = py + 6; - List placedLines = placedPeripheralHudLines(); - if (!placedLines.isEmpty()) { - graphics.drawString( - font, - Component.translatable("gui.computed.placed_hardware_title"), - px + 6, - contentY, - ComputedEditorTheme.ACCENT_MUTED, - false); - contentY += FUNCTION_LIB_TITLE_H; - for (Component line : placedLines) { - graphics.drawString(font, line, px + 8, contentY, ComputedEditorTheme.TEXT_PRIMARY, false); - contentY += font.lineHeight; - } - contentY += 4; - } - graphics.drawString(font, "Functions", px + 6, contentY, ComputedEditorTheme.TEXT_HEADER, false); - int ry = functionPickerNewRowY(py); - boolean hNew = mx >= px && mx < px + pw && my >= ry && my < ry + rh; - int newColor = hNew ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.TEXT_SECONDARY; - if (hNew) { - ComputedEditorStyle.drawMenuRow(graphics, px, ry, pw, rh, true, false); - } - graphics.drawString(font, "+ New function", px + 6, ry + 2, newColor, false); - ry = functionPickerDefsStartY(py); - List defs = - new ArrayList<>(functionStore.definitionsInOrder()); - int defsTop = ry; - int defsViewportH = functionPickerDefsViewportHeight(); - int nDefs = defs.size(); - functionLibraryListScroll = - Mth.clamp(functionLibraryListScroll, 0, Math.max(0, nDefs - FUNCTION_LIB_VISIBLE_ROWS)); - int defsListRight = px + pw - 2 - SCROLLER_TRACK_W; - graphics.enableScissor(px + 1, defsTop, defsListRight, defsTop + defsViewportH); - int visibleDefRows = Math.min(FUNCTION_LIB_VISIBLE_ROWS, Math.max(0, nDefs - functionLibraryListScroll)); - int defsTextMaxRight = defsListRight - 4; - for (int j = 0; j < visibleDefRows; j++) { - FunctionDefinitionStore.Definition def = defs.get(functionLibraryListScroll + j); - int rowY = defsTop + j * FUNCTION_LIB_NAME_ROW_H; - boolean renaming = def.id().equals(renamingLibraryFunctionId); - boolean selected = def.id().equals(selectedLibraryFunctionId); - boolean hwLocked = isFunctionLibraryDefinitionHardwareLocked(def); - boolean hr = - mx >= px && mx < px + pw && my >= rowY && my < rowY + FUNCTION_LIB_NAME_ROW_H; - if (renaming) { - ComputedEditorStyle.drawMenuRow( - graphics, px, rowY - 1, defsListRight - px, FUNCTION_LIB_NAME_ROW_H - 1, false, true); - } else if (selected) { - ComputedEditorStyle.drawMenuRow( - graphics, px, rowY - 1, defsListRight - px, FUNCTION_LIB_NAME_ROW_H - 1, false, true); - } else if (hr && !hwLocked) { - ComputedEditorStyle.drawMenuRow( - graphics, px, rowY - 1, defsListRight - px, FUNCTION_LIB_NAME_ROW_H - 1, true, false); - } else if (hwLocked) { - graphics.fill( - px + 2, - rowY - 1, - defsListRight, - rowY + FUNCTION_LIB_NAME_ROW_H - 2, - ComputedEditorTheme.DANGER_BACKGROUND); - } - int lx = px + 6; - if (renaming) { - drawLibraryFnRenameTextWithSelectionAndCaret(graphics, lx, rowY, defsTextMaxRight); - } else { - int tColor = - hwLocked - ? ComputedEditorTheme.STATUS_LOCKED_TEXT - : (selected ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.TEXT_SECONDARY); - graphics.drawString(font, def.name(), lx, rowY, tColor, false); - if (hwLocked) { - String hint = Component.translatable("gui.computed.function_needs_peripheral").getString(); - int hx = defsTextMaxRight - font.width(hint); - if (hx > lx + font.width(def.name()) + 4) { - graphics.drawString(font, hint, hx, rowY, ComputedEditorTheme.STATUS_LOCKED, false); - } - } - } - } - graphics.disableScissor(); - drawInsetVerticalScroller( - graphics, - px + pw - 2 - SCROLLER_TRACK_W, - defsTop, - defsViewportH, - functionLibraryListScroll, - nDefs, - FUNCTION_LIB_VISIBLE_ROWS); - ry = defsTop + defsViewportH; - int folderY = functionPickerFolderRowY(py); - boolean hFolder = - mx >= px && mx < px + pw && my >= folderY && my < folderY + rh && clientNestedFunctionsDirectory() != null; - if (hFolder) { - ComputedEditorStyle.drawMenuRow(graphics, px, folderY, pw, rh, true, false); - } - int ic = functionIconColumnX(px) + (FUNCTION_ICON_COLUMN_W - ICON_SIZE) / 2; - int iy = folderY + (rh - ICON_SIZE) / 2; - float ft = clientNestedFunctionsDirectory() != null ? 1f : 0.35f; - ComputedEditorStyle.beginTextureIcon(graphics); - graphics.setColor(ft, ft, ft, ease); - graphics.blit(ICON_FOLDER, ic, iy, 0, 0, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE); - graphics.setColor(1f, 1f, 1f, 1f); - graphics.drawString( - font, - "Open folder", - functionTextColumnX(px), - folderY + (rh - font.lineHeight) / 2 + 1, - clientNestedFunctionsDirectory() != null - ? ComputedEditorTheme.TEXT_PRIMARY - : ComputedEditorTheme.TEXT_DISABLED, - false); - - int impY = functionPickerImportRowY(py); - renderFunctionImportRow(graphics, mx, my, px, impY, pw, rh); - - drawFunctionLibraryFooterHints(graphics, px, py + ph - LIBRARY_HINT_BLOCK_H); - renderFunctionImportFlyout(graphics, mx, my); - } - - if (nestedFunctionDiskToolbarVisible()) { - renderNestedFunctionDiskToolbar(graphics, mx, my, ease); - } - } - - private void renderImportFromStringDialog(GuiGraphics graphics) { - if (!importDialogOpen) { - return; - } - graphics.fill(0, 0, width, height, ComputedEditorTheme.BACKGROUND_MODAL_SCRIM); - int boxW = Math.min(540, width - 40); - int boxH = Math.min(220, height - 40); - int bx = width / 2 - boxW / 2; - int by = height / 2 - boxH / 2; - drawMenuPanel(graphics, bx, by, boxW, boxH); - graphics.drawString( - font, - Component.translatable("gui.computed.share.import_title"), - bx + 8, - by + 8, - ComputedEditorTheme.TEXT_HEADER, - false); - int tx1 = bx + 8; - int ty1 = by + 24; - int tx2 = bx + boxW - 8; - int ty2 = by + boxH - 44; - ComputedEditorStyle.drawField(graphics, tx1, ty1, tx2 - tx1, ty2 - ty1, true, false); - String display = importDialogText.isEmpty() ? Component.translatable("gui.computed.share.import_placeholder").getString() : importDialogText; - int color = importDialogText.isEmpty() - ? ComputedEditorTheme.TEXT_TERTIARY - : ComputedEditorTheme.TEXT_PRIMARY; - graphics.enableScissor(tx1 + 2, ty1 + 2, tx2 - 2, ty2 - 2); - int maxW = tx2 - tx1 - 8; - List lines = font.split(Component.literal(display), maxW); - int y = ty1 + 4; - int start = Math.max(0, lines.size() - Math.max(1, (ty2 - ty1 - 8) / font.lineHeight)); - for (int i = start; i < lines.size(); i++) { - graphics.drawString(font, lines.get(i), tx1 + 4, y, color, false); - y += font.lineHeight; - if (y > ty2 - font.lineHeight) { - break; - } - } - graphics.disableScissor(); - - int btnW = 88; - int btnH = 20; - int importX = bx + boxW - btnW * 2 - 16; - int cancelX = bx + boxW - btnW - 8; - int btnY = by + boxH - 30; - boolean importHovered = mouseX >= importX && mouseX < importX + btnW && mouseY >= btnY && mouseY < btnY + btnH; - boolean cancelHovered = mouseX >= cancelX && mouseX < cancelX + btnW && mouseY >= btnY && mouseY < btnY + btnH; - ComputedEditorStyle.drawButton(graphics, importX, btnY, btnW, btnH, importHovered, true); - graphics.drawString( - font, - Component.translatable("gui.computed.share.import_button"), - importX + 16, - btnY + 6, - ComputedEditorTheme.TEXT_HEADER, - false); - ComputedEditorStyle.drawDangerButton(graphics, cancelX, btnY, btnW, btnH, cancelHovered); - graphics.drawString( - font, - Component.translatable("gui.computed.share.cancel_button"), - cancelX + 18, - btnY + 6, - ComputedEditorTheme.TEXT_HEADER, - false); - - if (!importDialogStatus.isEmpty()) { - graphics.drawString( - font, - importDialogStatus, - bx + 8, - by + boxH - 28, - importDialogStatusError ? ComputedEditorTheme.STATUS_ERROR_TEXT : ComputedEditorTheme.ACCENT_MUTED, - false); - } - } - - private void renderExportDialog(GuiGraphics graphics) { - if (!exportDialogOpen) { - return; - } - graphics.fill(0, 0, width, height, ComputedEditorTheme.BACKGROUND_MODAL_SCRIM); - int boxW = Math.min(540, width - 40); - int boxH = Math.min(220, height - 40); - int bx = width / 2 - boxW / 2; - int by = height / 2 - boxH / 2; - drawMenuPanel(graphics, bx, by, boxW, boxH); - graphics.drawString( - font, - Component.translatable("gui.computed.share.export_title"), - bx + 8, - by + 8, - ComputedEditorTheme.TEXT_HEADER, - false); - int tx1 = bx + 8; - int ty1 = by + 24; - int tx2 = bx + boxW - 8; - int ty2 = by + boxH - 44; - ComputedEditorStyle.drawField(graphics, tx1, ty1, tx2 - tx1, ty2 - ty1, false, false); - graphics.enableScissor(tx1 + 2, ty1 + 2, tx2 - 2, ty2 - 2); - int maxW = tx2 - tx1 - 8; - List lines = font.split(Component.literal(exportDialogText), maxW); - int y = ty1 + 4; - int start = Math.max(0, lines.size() - Math.max(1, (ty2 - ty1 - 8) / font.lineHeight)); - for (int i = start; i < lines.size(); i++) { - graphics.drawString(font, lines.get(i), tx1 + 4, y, ComputedEditorTheme.TEXT_PRIMARY, false); - y += font.lineHeight; - if (y > ty2 - font.lineHeight) { - break; - } - } - graphics.disableScissor(); - - int btnW = 88; - int btnH = 20; - int copyX = bx + boxW - btnW * 2 - 16; - int closeX = bx + boxW - btnW - 8; - int btnY = by + boxH - 30; - boolean copyHovered = mouseX >= copyX && mouseX < copyX + btnW && mouseY >= btnY && mouseY < btnY + btnH; - boolean closeHovered = mouseX >= closeX && mouseX < closeX + btnW && mouseY >= btnY && mouseY < btnY + btnH; - ComputedEditorStyle.drawButton(graphics, copyX, btnY, btnW, btnH, copyHovered, true); - graphics.drawString( - font, - Component.translatable("gui.computed.share.copy_button"), - copyX + 24, - btnY + 6, - ComputedEditorTheme.TEXT_HEADER, - false); - ComputedEditorStyle.drawDangerButton(graphics, closeX, btnY, btnW, btnH, closeHovered); - graphics.drawString( - font, - Component.translatable("gui.computed.share.close_button"), - closeX + 22, - btnY + 6, - ComputedEditorTheme.TEXT_HEADER, - false); - - if (!importDialogStatus.isEmpty()) { - graphics.drawString( - font, - importDialogStatus, - bx + 8, - by + boxH - 28, - importDialogStatusError ? 0xFFFF8888 : 0xFF88CC88, - false); - } - } - - /** Scales a 16×16 UI tile to {@code drawPx} for key caps and small icons. */ - private void blitScaledHintTile(GuiGraphics graphics, ResourceLocation icon, int x, int y, int drawPx) { - ComputedEditorStyle.beginTextureIcon(graphics); - graphics.pose().pushPose(); - graphics.pose().translate(x, y, 0); - float s = drawPx / (float) ICON_SIZE; - graphics.pose().scale(s, s, 1.0f); - graphics.blit(icon, 0, 0, 0, 0, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE); - graphics.pose().popPose(); - } - - /** - * Inset vertical scrollbar track with a thumb from {@link #ICON_SCROLLER_MULTICOLOR} / - * {@link #ICON_SCROLLER_DISABLED}. The thumb uses uniform scale (aspect preserved), is horizontally - * centered in the pit, and is top-aligned in the proportional scroll slot (including when disabled). - * {@code scroll} and {@code totalItems} match list semantics (e.g. {@link #functionLibraryListScroll}). - */ - private void drawInsetVerticalScroller( - GuiGraphics graphics, int trackLeft, int trackTop, int trackH, int scroll, int totalItems, int visibleRows) { - if (trackH < 10) { - return; - } - int trackW = SCROLLER_TRACK_W; - int pitX = trackLeft + 2; - int pitY = trackTop + 2; - int pitW = trackW - 4; - int pitH = trackH - 4; - if (pitW < 2 || pitH < 6) { - return; - } - boolean active = totalItems > visibleRows; - int maxScroll = Math.max(0, totalItems - visibleRows); - float fracVisible = visibleRows / (float) Math.max(1, totalItems); - int slotH = active ? Mth.clamp(Mth.ceil(fracVisible * pitH), 8, pitH) : pitH; - int thumbTravel = pitH - slotH; - int slotY = pitY + (maxScroll <= 0 ? 0 : (int) Math.round(scroll * (thumbTravel / (float) maxScroll))); - ComputedEditorStyle.drawScrollbar( - graphics, trackLeft, trackTop, trackW, trackH, slotY, slotH, active); - } - - private void drawFunctionLibraryFooterHints(GuiGraphics graphics, int x, int y) { - int hintColor = 0xFF556677; - int rowStride = LIBRARY_HINT_ICON + 4; - int tilePx = ICON_SIZE; - int keyGap = 2; - int iconStripX = functionIconColumnX(x); - int pairedWidth = tilePx + keyGap + tilePx; - int pairedX = iconStripX + (FUNCTION_ICON_COLUMN_W - pairedWidth) / 2; - int hintTextX = functionTextColumnX(x); - int iconY = y + (LIBRARY_HINT_ICON - tilePx) / 2; - int ty1 = y + (LIBRARY_HINT_ICON - font.lineHeight) / 2 + 1; - ComputedEditorStyle.beginTextureIcon(graphics); - blitScaledHintTile(graphics, KEY_CAP_ALT, pairedX, iconY, tilePx); - blitScaledHintTile(graphics, ICON_UI_CLICK, pairedX + tilePx + keyGap, iconY, tilePx); - graphics.drawString(font, "Place card", hintTextX, ty1, hintColor, false); - int y2 = y + rowStride; - int iconY2 = y2 + (LIBRARY_HINT_ICON - tilePx) / 2; - int ty2 = y2 + (LIBRARY_HINT_ICON - font.lineHeight) / 2 + 1; - int doubleClickX = iconStripX + (FUNCTION_ICON_COLUMN_W - tilePx) / 2; - blitScaledHintTile(graphics, ICON_UI_DOUBLE_CLICK, doubleClickX, iconY2, tilePx); - graphics.drawString(font, "Rename", hintTextX, ty2, hintColor, false); - } - - private boolean nestedFunctionDiskToolbarVisible() { - return functionStore != null && isEditingNestedFunction() && functionPickerOpen; - } - - private int nestedDiskToolbarY() { - return Math.min(menuEdgeBottom() - FULLSCREEN_BTN, schematicPickerY() + schematicPickerH() + 3); - } - - private int nestedDiskToolbarBtnX(int index) { - return schematicPickerX() + index * (FULLSCREEN_BTN + 4); - } - - private boolean nestedDiskToolbarBtnContains(double mx, double my, int index) { - int x = nestedDiskToolbarBtnX(index); - int y = nestedDiskToolbarY(); - return mx >= x && mx < x + FULLSCREEN_BTN && my >= y && my < y + FULLSCREEN_BTN; - } - - private boolean nestedDiskOpEnabled(int index) { - if (index == 1) { - return clientNestedFunctionsDirectory() != null; - } - return true; - } - - /** Single play/pause toggle + save when editing a function body. */ - private void renderNestedFunctionDiskToolbar(GuiGraphics graphics, int mx, int my, float ease) { - int alphaBg = (int) (200 * ease); - for (int i = 0; i < 2; i++) { - int bx = nestedDiskToolbarBtnX(i); - int by = nestedDiskToolbarY(); - boolean hov = nestedDiskToolbarBtnContains(mx, my, i); - boolean enabled = nestedDiskOpEnabled(i); - ResourceLocation icon = i == 0 ? (nestedFunctionTestPlaying ? ICON_PAUSE : ICON_PLAY) : ICON_SAVE_DISK; - int fillRgb = - !enabled - ? 0x1a1a1a - : (nestedFunctionTestPlaying && i == 0) - ? 0x2a4a3a - : (hov ? 0x3a3a3a : 0x2a2a2a); - graphics.fill(bx, by, bx + FULLSCREEN_BTN, by + FULLSCREEN_BTN, (alphaBg << 24) | fillRgb); - graphics.renderOutline( - bx, - by, - FULLSCREEN_BTN, - FULLSCREEN_BTN, - ((int) (255 * ease) << 24) | (enabled ? 0x777777 : 0x444444)); - int ix = bx + (FULLSCREEN_BTN - ICON_SIZE) / 2; - int iy = by + (FULLSCREEN_BTN - ICON_SIZE) / 2; - float tint = enabled ? 1.0f : 0.35f; - graphics.setColor(tint, tint, tint, ease); - graphics.blit(icon, ix, iy, 0, 0, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE); - graphics.setColor(1f, 1f, 1f, 1f); - } - } - - private void syncCurrentNestedFunctionToStore() { - if (functionStore == null || !isEditingNestedFunction()) { - return; - } - FunctionCardNode host = functionEditStack.peek().openedHost(); - FunctionDefinitionStore.Definition def = functionStore.get(host.getFunctionId()); - String name = def != null ? def.name() : "Function"; - functionStore.put(host.getFunctionId(), name, graph.save()); - } - - private void saveNestedFunctionToClientFile() { - Path root = clientNestedFunctionsDirectory(); - if (root == null || !isEditingNestedFunction()) { - playUiClick(0.85f); - return; - } - try { - Files.createDirectories(root); - } catch (IOException e) { - playUiClick(0.85f); - return; - } - FunctionCardNode host = functionEditStack.peek().openedHost(); - FunctionDefinitionStore.Definition def = functionStore.get(host.getFunctionId()); - String name = def != null ? def.name() : "Function"; - String base = safeFunctionFileBase(name); - Path file = root.resolve(base + ".nbt"); - try { - FunctionDefinitionStore emptyLibrary = new FunctionDefinitionStore(); - CompoundTag program = ProgramCodec.write(ProgramBridge.snapshot(graph, emptyLibrary, 0L)); - NbtIo.writeCompressed(program, file); - playUiClick(1.06f); - } catch (IOException e) { - playUiClick(0.85f); - } - } - - private static String safeFunctionFileBase(String displayName) { - String s = displayName == null ? "" : displayName.trim(); - if (s.isEmpty()) { - return "function"; - } - return s.replaceAll("[^a-zA-Z0-9._\\-]+", "_"); - } - - private void openNestedFunctionsFolder() { - Path root = clientNestedFunctionsDirectory(); - if (root == null) { - playUiClick(0.85f); - return; - } - try { - Files.createDirectories(root); - } catch (IOException e) { - playUiClick(0.85f); - return; - } - clientRevealNestedFunctionsFolder(root); - playUiClick(1.02f); - } - - private boolean handleNestedDiskToolbarClick(double mouseX, double mouseY, int button) { - if (button != 0 || !nestedFunctionDiskToolbarVisible()) { - return false; - } - for (int i = 0; i < 2; i++) { - if (!nestedDiskToolbarBtnContains(mouseX, mouseY, i)) { - continue; - } - if (!nestedDiskOpEnabled(i)) { - playUiClick(0.85f); - return true; - } - if (i == 0) { - nestedFunctionTestPlaying = !nestedFunctionTestPlaying; - playUiClick(nestedFunctionTestPlaying ? 1.04f : 0.98f); - } else { - saveNestedFunctionToClientFile(); - } - return true; - } - return false; - } - - private void handleFunctionImportFlyoutClick(double mouseX, double mouseY) { - int nf = functionDiscImportFiles.size(); - if (nf == 0) { - return; - } - int[] b = new int[4]; - layoutFunctionImportFlyout(b); - int fx = b[0]; - int fy = b[1]; - int fw = b[2]; - int rowH = FUNCTION_LIB_NAME_ROW_H; - int vis = FUNCTION_LIB_VISIBLE_ROWS; - int contentTop = fy + 6; - int rowAreaBottom = contentTop + vis * rowH; - if (mouseX < fx || mouseX >= fx + fw || mouseY < contentTop || mouseY >= rowAreaBottom) { - return; - } - int row = (int) ((mouseY - contentTop) / rowH); - if (row < 0 || row >= vis) { - return; - } - int fileIdx = functionDiscImportListScroll + row; - if (fileIdx >= nf) { - return; - } - commitLibraryFunctionRename(); - importDiscFileAtIndex(fileIdx); - functionImportSubmenuOpen = false; - } - - private void handleFunctionPickerClick(double mouseX, double mouseY) { - int px = schematicPickerX(); - int py = schematicPickerY(); - int pw = schematicPickerW(); - int rh = menuRowHeight(); - if (mouseX < px || mouseX >= px + pw || mouseY < py + 6 || mouseY >= py + schematicPickerH()) { - return; - } - int impY = functionPickerImportRowY(py); - boolean onImportRow = mouseY >= impY && mouseY < impY + rh; - if (functionImportSubmenuOpen && !onImportRow) { - functionImportSubmenuOpen = false; - } - if (onImportRow) { - commitLibraryFunctionRename(); - if (clientNestedFunctionsDirectory() == null) { - playUiClick(0.85f); - return; - } - refreshFunctionDiscFileList(); - functionImportSubmenuOpen = !functionImportSubmenuOpen; - if (functionImportSubmenuOpen) functionDiscImportListScroll = 0; - playUiClick(functionImportSubmenuOpen ? 1.02f : 0.98f); - return; - } - int newTop = functionPickerNewRowY(py); - if (mouseY >= newTop && mouseY < newTop + rh) { - commitLibraryFunctionRename(); - functionPickerOpen = false; - newFunctionNamingOpen = true; - newFunctionNameBuffer = ""; - playUiClick(1.02f); - return; - } - int defsTop = functionPickerDefsStartY(py); - int defsViewportH = functionPickerDefsViewportHeight(); - if (mouseY >= defsTop && mouseY < defsTop + defsViewportH) { - int row = (int) ((mouseY - defsTop) / FUNCTION_LIB_NAME_ROW_H); - List defs = - new ArrayList<>(functionStore.definitionsInOrder()); - int i = functionLibraryListScroll + row; - if (row < 0 || row >= FUNCTION_LIB_VISIBLE_ROWS || i < 0 || i >= defs.size()) { - return; - } - FunctionDefinitionStore.Definition def = defs.get(i); - if (isFunctionLibraryDefinitionHardwareLocked(def)) { - playUiClick(0.82f); - return; - } - if (renamingLibraryFunctionId != null && !def.id().equals(renamingLibraryFunctionId)) { - commitLibraryFunctionRename(); - } - if (Screen.hasAltDown()) { - placeFunctionCardFromLibrary(def.id(), mouseX, mouseY); - return; - } - selectedLibraryFunctionId = def.id(); - long now = net.minecraft.Util.getMillis(); - if (def.id().equals(lastLibraryFunctionClickId) - && now - lastLibraryFunctionClickAtMs <= SECTION_DOUBLE_CLICK_MS) { - startLibraryFunctionRename(def.id(), def.name()); - } - lastLibraryFunctionClickId = def.id(); - lastLibraryFunctionClickAtMs = now; - playUiClick(1.0f); - return; - } - int folderY = functionPickerFolderRowY(py); - if (mouseY >= folderY && mouseY < folderY + rh) { - commitLibraryFunctionRename(); - openNestedFunctionsFolder(); - return; - } - } - - private void placeFunctionCardFromLibrary(UUID functionId, double screenMx, double screenMy) { - commitLibraryFunctionRename(); - if (functionStore != null) { - FunctionDefinitionStore.Definition def = functionStore.get(functionId); - if (def != null && isFunctionLibraryDefinitionHardwareLocked(def)) { - playUiClick(0.82f); - return; - } - } - int nx = screenToGraphX(screenMx); - int ny = screenToGraphY(screenMy); - recordCheckpointBeforeEdit(); - graph.addNode(FunctionCardNode.createPlaced(nx, ny, functionId, functionStore)); - functionPickerOpen = false; - playUiClick(1.06f); - } - - private void renderFullscreenToggle(GuiGraphics graphics, int mx, int my, float ease) { - int x = fullscreenBtnX(); - int y = fullscreenBtnY(); - boolean hov = fullscreenBtnContains(mx, my); - ComputedEditorStyle.drawButton(graphics, x, y, FULLSCREEN_BTN, FULLSCREEN_BTN, hov, editorFullscreen); - ResourceLocation icon = editorFullscreen ? ICON_MINIMIZE : ICON_MAXIMIZE; - int ix = x + (FULLSCREEN_BTN - ICON_SIZE) / 2; - int iy = y + (FULLSCREEN_BTN - ICON_SIZE) / 2; - graphics.blit(icon, ix, iy, 0, 0, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE); - } - - private void clearStickyBrowseRoot() { - stickyBrowseRootId = null; - } - - /** Builds flyout path, then clamps menu position using that path's stack width. */ - private void layoutBrowseMenuForPointer(int mx, int my) { - updateMenuFlyoutPath(mx, my); - clampBrowseMenuOnScreen(); - } - - /** Left column (header + list); padded so clamp/layout jitter does not lose hover on the main panel. */ - private boolean menuMainColumnHitSloppy(int mx, int my, int pad) { - java.util.List mainRows = browseRowsFor(NodeMenuRegistry.ROOT); - int mw = browsePanelWidth(mainRows, menuEdgeRight() - menuEdgeLeft()); - int h = menuHeaderHeight() + mainRows.size() * menuRowHeight(); - return mx >= menuX - pad - && mx < menuX + mw + pad - && my >= menuY - pad - && my < menuY + h + pad; - } - - private boolean isRootCategoryWithSubmenu( - net.minecraft.resources.ResourceLocation id, java.util.List mainRows) { - for (BrowseRow r : mainRows) { - if (r instanceof BrowseCategoryRow b && b.id().equals(id)) { - return submenuHasContent(id); - } - } - return false; - } - - private void rebuildSearchHitRows() { - searchHitRows.clear(); - if (searchQuery.trim().isEmpty()) { - return; - } - for (NodeMenuRegistry.MenuEntry e : NodeMenuRegistry.filterEntries(searchQuery)) { - searchHitRows.add(new BrowseNodeRow(e.nodeType(), e.label())); - } - } - - private java.util.List browseRowsFor(net.minecraft.resources.ResourceLocation parentId) { - java.util.ArrayList list = new java.util.ArrayList<>(); - for (NodeMenuRegistry.Category c : NodeMenuRegistry.getChildCategories(parentId)) { - if (submenuHasContent(c.id())) { - list.add(new BrowseCategoryRow(c.id(), c.title())); - } - } - for (NodeMenuRegistry.MenuEntry e : NodeMenuRegistry.getEntriesIn(parentId)) { - list.add(new BrowseNodeRow(e.nodeType(), e.label())); - } - return list; - } - - private static boolean submenuHasContent(net.minecraft.resources.ResourceLocation catId) { - return !NodeMenuRegistry.getChildCategories(catId).isEmpty() - || !NodeMenuRegistry.getEntriesIn(catId).isEmpty(); - } - - private int browsePanelWidth(java.util.List rows, int maxWidth) { - int w = menuMinColWidth(); - for (BrowseRow row : rows) { - Component lab = row instanceof BrowseCategoryRow c ? c.label() : ((BrowseNodeRow) row).label(); - boolean sub = row instanceof BrowseCategoryRow c && submenuHasContent(c.id()); - w = Math.max(w, font.width(lab) + (sub ? 28 : 14)); - } - int cap = Math.max(menuMinColWidth(), Math.min(maxWidth, menuEdgeRight() - menuEdgeLeft())); - return Math.min(w, cap); - } - - /** Total width of root + flyouts for {@code path} at current {@link #menuX}. */ - private int browseStackWidthPx(java.util.List path) { - java.util.List mainRows = browseRowsFor(NodeMenuRegistry.ROOT); - int maxStrip = menuEdgeRight() - menuEdgeLeft(); - int mainW = browsePanelWidth(mainRows, maxStrip); - int left = menuX + mainW + MENU_GAP; - int total = mainW; - for (net.minecraft.resources.ResourceLocation catId : path) { - java.util.List content = browseRowsFor(catId); - int fw = browsePanelWidth(content, Math.max(menuMinColWidth(), menuEdgeRight() - left - MENU_GAP)); - total += MENU_GAP + fw; - left += MENU_GAP + fw; - } - return total; - } - - private void clampBrowseMenuOnScreen() { - java.util.List mainRows = browseRowsFor(NodeMenuRegistry.ROOT); - int rh = menuRowHeight(); - int hh = menuHeaderHeight(); - int mainH = hh + mainRows.size() * rh; - int stackW = browseStackWidthPx(menuFlyoutPath); - - int el = menuEdgeLeft(); - int er = menuEdgeRight(); - int et = menuEdgeTop(); - int eb = menuEdgeBottom(); - - if (menuY + mainH > eb) { - menuY = eb - mainH; - } - if (menuY < et) { - menuY = et; - } - - if (menuX + stackW > er) { - menuX = Math.max(el, er - stackW); - } - if (menuX < el) { - menuX = el; - } - } - - private int computeSearchMenuWidthPx() { - int mw = menuMinColWidth(); - for (BrowseNodeRow r : searchHitRows) { - mw = Math.max(mw, font.width(r.label()) + 20); - } - return Math.min(mw, menuEdgeRight() - menuEdgeLeft()); - } - - private int menuMaxSearchVisibleRows() { - int rh = menuRowHeight(); - int hh = menuHeaderHeight(); - int avail = menuEdgeBottom() - menuY - hh - rh; - return Math.max(3, Math.min(MENU_MAX_VISIBLE, avail / rh)); - } - - private void clampSearchMenuOnScreen() { - int rh = menuRowHeight(); - int hh = menuHeaderHeight(); - int maxVis = menuMaxSearchVisibleRows(); - int visible = Math.min(maxVis, searchHitRows.size()); - int mh = hh + visible * rh; - if (searchHitRows.size() > visible) { - mh += rh - 2; - } - - int el = menuEdgeLeft(); - int er = menuEdgeRight(); - int et = menuEdgeTop(); - int eb = menuEdgeBottom(); - - int mw = computeSearchMenuWidthPx(); - - if (menuY + mh > eb) { - menuY = eb - mh; - } - if (menuY < et) { - menuY = et; - } - if (menuX + mw > er) { - menuX = er - mw; - } - if (menuX < el) { - menuX = el; - } - } - - private void updateMenuFlyoutPath(int mx, int my) { - java.util.List mainRows = browseRowsFor(NodeMenuRegistry.ROOT); - int maxStrip = menuEdgeRight() - menuEdgeLeft(); - int mainW = browsePanelWidth(mainRows, maxStrip); - int mainListTop = menuY + menuHeaderHeight(); - int mainListH = mainRows.size() * menuRowHeight(); - MenuRect mainList = new MenuRect(menuX, mainListTop, mainW, mainListH); - - java.util.ArrayList prevBrowsePath = - new java.util.ArrayList<>(menuFlyoutPath); - menuFlyoutPath.clear(); - - BrowseCategoryRow hoveredRootFromMain = null; - // List rows only (below header). Sloppy X so edge clicks register after clamp. - boolean mainGeom = - mainList.contains(mx, my) || (menuMainColumnHitSloppy(mx, my, 4) && my >= mainListTop); - if (mainGeom && !browseMouseBlockedByDeeperPanel(mx, my, -1, prevBrowsePath)) { - int idx = (my - mainListTop) / menuRowHeight(); - if (idx >= 0 && idx < mainRows.size() && mainRows.get(idx) instanceof BrowseCategoryRow bcr - && submenuHasContent(bcr.id())) { - hoveredRootFromMain = bcr; - } - } - - net.minecraft.resources.ResourceLocation rootId = null; - // Main-column opener wins over sticky so switching rows updates the flyout immediately. - if (hoveredRootFromMain != null) { - rootId = hoveredRootFromMain.id(); - stickyBrowseRootId = rootId; - } else if (stickyBrowseRootId != null) { - if (isRootCategoryWithSubmenu(stickyBrowseRootId, mainRows)) { - rootId = stickyBrowseRootId; - } else { - clearStickyBrowseRoot(); - } - } - - if (rootId == null) { - return; - } - - java.util.ArrayList path = new java.util.ArrayList<>(); - path.add(rootId); - - /* - * Pointer on a nested column is not inside the first flyout's union, so we must either extend - * from a direct hit or jump into the child branch whose flyout already contains the pointer. - * Root flyouts for other categories are not consulted (no ghost stacks). - */ - while (path.size() < 24) { - FlyoutHitPanel panel = flyoutHitPanelForPath(path, mainRows, mainW); - if (panel == null) { - break; - } - if (panel.unionContains(mx, my)) { - BrowseRow hit = panel.rowAt(mx, my); - if (!(hit instanceof BrowseCategoryRow bcr) || !submenuHasContent(bcr.id())) { - break; - } - path.add(bcr.id()); - continue; - } - BrowseCategoryRow jump = null; - int bestScore = Integer.MIN_VALUE; - int jumpIdx = -1; - for (BrowseRow r : panel.rows) { - if (!(r instanceof BrowseCategoryRow bcr) || !submenuHasContent(bcr.id())) { - continue; - } - java.util.ArrayList longer = - new java.util.ArrayList<>(path); - longer.add(bcr.id()); - FlyoutHitPanel deeper = flyoutHitPanelForPath(longer, mainRows, mainW); - if (deeper == null || !deeper.unionContains(mx, my)) { - continue; - } - boolean inFly = deeper.flyout().contains(mx, my); - boolean onParentRow = browsePointerOnCategoryRowInPanel(panel, bcr, mx, my); - boolean stickyHere = - prevBrowsePath.size() > path.size() - && prevBrowsePath.get(path.size()).equals(bcr.id()); - /* - * Sibling submenus share X alignment; a tall flyout can overlap another sibling’s - * hypothetical rect — score so the parent row and sticky branch win, and flyout - * interior beats bridge-only. - */ - int score = inFly ? 100 : 10; - if (onParentRow) { - score += 1000; - } - if (stickyHere) { - score += 50; - } - int idx = indexOfCategory(panel.rows, bcr.id()); - if (score > bestScore) { - bestScore = score; - jump = bcr; - jumpIdx = idx; - } else if (score == bestScore && idx > jumpIdx) { - jump = bcr; - jumpIdx = idx; - } - } - if (jump != null) { - path.add(jump.id()); - continue; - } - break; - } - - menuFlyoutPath.addAll(path); - - if (!menuBrowseStackBoundsContains(mx, my, mainRows, mainW)) { - clearStickyBrowseRoot(); - menuFlyoutPath.clear(); - } - } - - /** - * True if the pointer is over the main column or the axis-aligned hull of the current flyout stack - * (covers gaps between columns). Only uses {@link #menuFlyoutPath}; no other root's geometry. - */ - private boolean menuBrowseStackBoundsContains( - int mx, int my, java.util.List mainRows, int mainW) { - if (menuMainColumnHitSloppy(mx, my, 6)) { - return true; - } - if (menuFlyoutPath.isEmpty()) { - return false; - } - int minX = menuX + mainW; - int minY = menuY; - int maxX = menuX + mainW; - int maxY = menuY + menuHeaderHeight() + mainRows.size() * menuRowHeight(); - for (int d = 0; d < menuFlyoutPath.size(); d++) { - java.util.ArrayList prefix = - new java.util.ArrayList<>(menuFlyoutPath.subList(0, d + 1)); - FlyoutGeom g = computeFlyoutGeom(prefix, mainRows, mainW); - if (g == null) { - continue; - } - MenuRect r = g.flyout(); - minX = Math.min(minX, r.x); - minY = Math.min(minY, r.y); - maxX = Math.max(maxX, r.x + r.w); - maxY = Math.max(maxY, r.y + r.h); - } - int pad = 4; - return mx >= minX - pad && mx < maxX + pad && my >= minY - pad && my < maxY + pad; - } - - private static boolean bridgeContains(int mx, int my, int bridgeLeft, int rowTop, int rowBottom, int flyLeft) { - return mx >= bridgeLeft && mx < flyLeft && my >= rowTop && my < rowBottom; - } - - private record FlyoutGeom(MenuRect flyout, int rowTopInParent, int rowBottomInParent) {} - - private record FlyoutHitPanel( - MenuRect flyout, - int flyListTop, - java.util.List rows, - int rowHeight, - int parentRowTop, - int parentRowBottom, - int bridgeLeft) { - boolean unionContains(int mx, int my) { - if (flyout.contains(mx, my)) { - return true; - } - return bridgeContains(mx, my, bridgeLeft, parentRowTop, parentRowBottom, flyout.x); - } - - BrowseRow rowAt(int mx, int my) { - if (!flyout.contains(mx, my)) { - return null; - } - int idx = (my - flyListTop) / rowHeight; - if (idx < 0 || idx >= rows.size()) { - return null; - } - return rows.get(idx); - } - } - - private FlyoutHitPanel flyoutHitPanelForPath( - java.util.List path, - java.util.List mainRows, - int mainW) { - if (path.isEmpty()) { - return null; - } - FlyoutGeom g = computeFlyoutGeom(path, mainRows, mainW); - if (g == null) { - return null; - } - net.minecraft.resources.ResourceLocation leaf = path.get(path.size() - 1); - java.util.List rows = browseRowsFor(leaf); - int flyListTop = g.flyout().y; - - int parentListTop; - int bridgeLeft; - java.util.List parentRows; - if (path.size() == 1) { - parentListTop = menuY + menuHeaderHeight(); - bridgeLeft = menuX + mainW; - parentRows = mainRows; - } else { - java.util.ArrayList parentPath = - new java.util.ArrayList<>(path.subList(0, path.size() - 1)); - FlyoutGeom pg = computeFlyoutGeom(parentPath, mainRows, mainW); - if (pg == null) { - return null; - } - parentListTop = pg.flyout().y; - bridgeLeft = pg.flyout().x + pg.flyout().w; - parentRows = browseRowsFor(path.get(path.size() - 2)); - } - int pIdx = indexOfCategory(parentRows, leaf); - if (pIdx < 0) { - return null; - } - int rh = menuRowHeight(); - int rowTop = parentListTop + pIdx * rh; - int rowBottom = rowTop + rh; - return new FlyoutHitPanel(g.flyout(), flyListTop, rows, rh, rowTop, rowBottom, bridgeLeft); - } - - private FlyoutGeom computeFlyoutGeom( - java.util.List path, - java.util.List mainRows, - int mainW) { - if (path.isEmpty()) { - return null; - } - int mainListTop = menuY + menuHeaderHeight(); - int left = menuX + mainW + MENU_GAP; - java.util.List parentRows = mainRows; - int parentListTop = mainListTop; - FlyoutGeom last = null; - for (int i = 0; i < path.size(); i++) { - net.minecraft.resources.ResourceLocation catId = path.get(i); - int idx = indexOfCategory(parentRows, catId); - if (idx < 0) { - return null; - } - int anchorTop = parentListTop + idx * menuRowHeight(); - int anchorBottom = anchorTop + menuRowHeight(); - java.util.List content = browseRowsFor(catId); - int fw = browsePanelWidth( - content, Math.max(menuMinColWidth(), menuEdgeRight() - left - MENU_GAP)); - int fh = content.size() * menuRowHeight(); - int flyTop = anchorTop; - flyTop = Math.max(menuEdgeTop(), Math.min(flyTop, menuEdgeBottom() - fh)); - MenuRect fly = new MenuRect(left, flyTop, fw, fh); - last = new FlyoutGeom(fly, anchorTop, anchorBottom); - if (i < path.size() - 1) { - left = left + fw + MENU_GAP; - parentRows = content; - parentListTop = flyTop; - } - } - return last; - } - - /** - * Flyouts drawn later in the stack sit visually on top; pointer over a deeper panel must not activate - * rows in shallower columns (otherwise hidden rows “light up” on hover). - * - * @param panelDepth {@code -1} for the root browse column, {@code 0} for the first flyout, etc. - * @param activeStack path stack to test (usually {@link #menuFlyoutPath}; while rebuilding the flyout - * path, pass the previous frame’s path). - */ - private boolean browseMouseBlockedByDeeperPanel( - int mx, - int my, - int panelDepth, - java.util.List activeStack) { - if (activeStack.isEmpty()) { - return false; - } - java.util.List mainRows = browseRowsFor(NodeMenuRegistry.ROOT); - int maxStrip = menuEdgeRight() - menuEdgeLeft(); - int mainW = browsePanelWidth(mainRows, maxStrip); - for (int d = panelDepth + 1; d < activeStack.size(); d++) { - java.util.ArrayList prefix = - new java.util.ArrayList<>(activeStack.subList(0, d + 1)); - FlyoutGeom g = computeFlyoutGeom(prefix, mainRows, mainW); - if (g != null && g.flyout().contains(mx, my)) { - return true; - } - } - return false; - } - - private boolean browseMouseBlockedByDeeperPanel(int mx, int my, int panelDepth) { - return browseMouseBlockedByDeeperPanel(mx, my, panelDepth, menuFlyoutPath); - } - - /** True if the pointer is on {@code bcr}’s row inside {@code panel}’s list (not a sibling’s). */ - private boolean browsePointerOnCategoryRowInPanel( - FlyoutHitPanel panel, BrowseCategoryRow bcr, int mx, int my) { - int ri = indexOfCategory(panel.rows(), bcr.id()); - if (ri < 0) { - return false; - } - int y0 = panel.flyListTop() + ri * panel.rowHeight(); - int fx = panel.flyout().x; - int fw = panel.flyout().w; - return mx >= fx && mx < fx + fw && my >= y0 && my < y0 + panel.rowHeight(); - } - - private int indexOfCategory(java.util.List rows, net.minecraft.resources.ResourceLocation id) { - for (int i = 0; i < rows.size(); i++) { - if (rows.get(i) instanceof BrowseCategoryRow bcr && bcr.id().equals(id)) { - return i; - } - } - return -1; - } - - private void renderSearchMenu(GuiGraphics graphics) { - graphics.pose().pushPose(); - graphics.pose().translate(0, 0, 200); - - if (!searchQuery.trim().isEmpty()) { - renderSearchFlatMenu(graphics); - graphics.pose().popPose(); - return; - } - - java.util.List mainRows = browseRowsFor(NodeMenuRegistry.ROOT); - int maxStrip = menuEdgeRight() - menuEdgeLeft(); - int mw = browsePanelWidth(mainRows, maxStrip); - int mainBodyH = mainRows.size() * menuRowHeight(); - int mh = menuHeaderHeight() + mainBodyH; - - drawMenuPanel(graphics, menuX, menuY, mw, mh); - graphics.drawString(font, "Add node", menuX + 4, menuY + 4, ComputedEditorTheme.ACCENT_MUTED, false); - graphics.drawString( - font, - "> " + searchQuery + (((System.currentTimeMillis() / 500) % 2 == 0) ? "_" : " "), - menuX + 4, - menuY + 4 + menuRowHeight(), - ComputedEditorTheme.ACCENT, - false); - drawBrowseRows( - graphics, - mainRows, - menuX, - menuY + menuHeaderHeight(), - mw, - mouseX, - mouseY, - true, - -1); - - for (int depth = 0; depth < menuFlyoutPath.size(); depth++) { - java.util.List prefix = - new java.util.ArrayList<>(menuFlyoutPath.subList(0, depth + 1)); - FlyoutGeom g = computeFlyoutGeom(prefix, mainRows, mw); - if (g == null) { - continue; - } - net.minecraft.resources.ResourceLocation cat = prefix.get(prefix.size() - 1); - java.util.List rows = browseRowsFor(cat); - drawMenuPanel(graphics, g.flyout().x, g.flyout().y, g.flyout().w, g.flyout().h); - drawBrowseRows( - graphics, - rows, - g.flyout().x, - g.flyout().y, - g.flyout().w, - mouseX, - mouseY, - true, - depth); - } - - graphics.pose().popPose(); - } - - private void renderSearchFlatMenu(GuiGraphics graphics) { - int mw = computeSearchMenuWidthPx(); - int visible = Math.min(menuMaxSearchVisibleRows(), searchHitRows.size()); - int mh = menuHeaderHeight() + visible * menuRowHeight(); - if (searchHitRows.size() > visible) { - mh += menuRowHeight() - 2; - } - - drawMenuPanel(graphics, menuX, menuY, mw, mh); - graphics.drawString( - font, - "Filter (all categories)", - menuX + 4, - menuY + 4, - ComputedEditorTheme.ACCENT_MUTED, - false); - graphics.drawString( - font, - "> " + searchQuery + (((System.currentTimeMillis() / 500) % 2 == 0) ? "_" : " "), - menuX + 4, - menuY + 4 + menuRowHeight(), - ComputedEditorTheme.ACCENT, - false); - for (int i = 0; i < visible; i++) { - BrowseNodeRow row = searchHitRows.get(i); - int ry = menuY + menuHeaderHeight() + i * menuRowHeight(); - boolean hovered = mouseY >= ry && mouseY < ry + menuRowHeight() && mouseX >= menuX && mouseX <= menuX + mw; - int color = (i == 0 || hovered) ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.TEXT_SECONDARY; - if (i == 0 || hovered) { - ComputedEditorStyle.drawMenuRow( - graphics, menuX, ry, mw, menuRowHeight(), hovered, i == 0); - } - boolean locked = isEditorPeripheralLocked(row.nodeType()); - int rowColor = locked ? ComputedEditorTheme.STATUS_LOCKED_TEXT : color; - graphics.drawString(font, row.label(), menuX + 6, ry + 1, rowColor, false); - if (hovered) { - queueEditorTooltip( - NodeDescriptionCatalog.component(row.nodeType(), row.label()), mouseX, mouseY); - } - } - if (searchHitRows.size() > visible) { - graphics.drawString( - font, - "(" + searchHitRows.size() + " total — type to narrow)", - menuX + 4, - menuY + menuHeaderHeight() + visible * menuRowHeight() + 2, - ComputedEditorTheme.TEXT_TERTIARY, - false); - } - } - - private static void drawMenuPanel(GuiGraphics graphics, int x, int y, int w, int h) { - ComputedEditorStyle.drawMenuPanel(graphics, x, y, w, h); - } - - private void drawBrowseRows( - GuiGraphics graphics, - java.util.List rows, - int rx, - int ry, - int rw, - int mx, - int my, - boolean showSubArrow, - int occlusionPanelDepth) { - int rh = menuRowHeight(); - for (int i = 0; i < rows.size(); i++) { - BrowseRow row = rows.get(i); - int y0 = ry + i * rh; - boolean hovered = my >= y0 && my < y0 + rh && mx >= rx && mx < rx + rw; - if (hovered && browseMouseBlockedByDeeperPanel(mx, my, occlusionPanelDepth)) { - hovered = false; - } - int color = hovered ? ComputedEditorTheme.TEXT_HEADER : ComputedEditorTheme.TEXT_SECONDARY; - if (hovered) { - ComputedEditorStyle.drawMenuRow(graphics, rx, y0, rw, rh, true, false); - } - graphics.enableScissor(rx + 1, y0, rx + rw - 1, y0 + rh); - if (row instanceof BrowseCategoryRow c) { - boolean sub = submenuHasContent(c.id()); - Component text = - sub && showSubArrow ? Component.empty().append(c.label()).append(" ›") : c.label(); - graphics.drawString(font, text, rx + 6, y0 + 1, color, false); - } else if (row instanceof BrowseNodeRow n) { - boolean locked = isEditorPeripheralLocked(n.nodeType()); - int rowColor = locked ? ComputedEditorTheme.STATUS_LOCKED_TEXT : color; - graphics.drawString(font, n.label(), rx + 6, y0 + 1, rowColor, false); - } - graphics.disableScissor(); - if (hovered && row instanceof BrowseNodeRow n) { - queueEditorTooltip(NodeDescriptionCatalog.component(n.nodeType(), n.label()), mx, my); - } - } - } - - private boolean menuBrowseContainsMouse(int mx, int my) { - if (!searchQuery.trim().isEmpty()) { - return false; - } - java.util.List mainRows = browseRowsFor(NodeMenuRegistry.ROOT); - int mw = browsePanelWidth(mainRows, menuEdgeRight() - menuEdgeLeft()); - return menuBrowseStackBoundsContains(mx, my, mainRows, mw); - } - - private boolean menuSearchFlatContainsMouse(int mx, int my) { - if (searchQuery.trim().isEmpty()) { - return false; - } - int mw = computeSearchMenuWidthPx(); - int visible = Math.min(menuMaxSearchVisibleRows(), searchHitRows.size()); - int mh = menuHeaderHeight() + visible * menuRowHeight(); - if (searchHitRows.size() > visible) { - mh += menuRowHeight() - 2; - } - return new MenuRect(menuX, menuY, mw, mh).contains(mx, my); - } - - private net.minecraft.resources.ResourceLocation hitBrowseNodeTypeAt(int mx, int my) { - if (!searchQuery.trim().isEmpty()) { - int mw = computeSearchMenuWidthPx(); - int visible = Math.min(menuMaxSearchVisibleRows(), searchHitRows.size()); - int listTop = menuY + menuHeaderHeight(); - if (mx >= menuX && mx <= menuX + mw && my >= listTop && my < listTop + visible * menuRowHeight()) { - int idx = (my - listTop) / menuRowHeight(); - if (idx >= 0 && idx < searchHitRows.size()) { - return searchHitRows.get(idx).nodeType(); - } - } - return null; - } - - java.util.List mainRows = browseRowsFor(NodeMenuRegistry.ROOT); - int mw = browsePanelWidth(mainRows, menuEdgeRight() - menuEdgeLeft()); - int mainListTop = menuY + menuHeaderHeight(); - if (mx >= menuX && mx < menuX + mw && my >= mainListTop && my < mainListTop + mainRows.size() * menuRowHeight()) { - if (!browseMouseBlockedByDeeperPanel(mx, my, -1)) { - int idx = (my - mainListTop) / menuRowHeight(); - if (idx >= 0 && idx < mainRows.size() && mainRows.get(idx) instanceof BrowseNodeRow n) { - return n.nodeType(); - } - } - } - - for (int d = menuFlyoutPath.size() - 1; d >= 0; d--) { - java.util.ArrayList prefix = - new java.util.ArrayList<>(menuFlyoutPath.subList(0, d + 1)); - FlyoutHitPanel panel = flyoutHitPanelForPath(prefix, mainRows, mw); - if (panel == null) { - continue; - } - if (browseMouseBlockedByDeeperPanel(mx, my, d)) { - continue; - } - BrowseRow r = panel.rowAt(mx, my); - if (r instanceof BrowseNodeRow n) { - return n.nodeType(); - } - } - return null; - } - - private void drawGrid(GuiGraphics graphics) { - graphics.pose().pushPose(); - graphics.pose().translate(panX, panY, 0); - float gScale = editorContentScale(); - int gridSize = Math.max(2, Math.round(GRID_SPACING_SCREEN_PX / gScale)); - int lineW = Math.max(1, Math.round(GRID_LINE_WIDTH_SCREEN_PX / gScale)); - lineW = Math.min(lineW, Math.max(1, gridSize - 1)); - int margin = gridSize + 4; - int startX = (int)(-panX - (width / 2f) / gScale - margin); - int startY = (int)(-panY - (height / 2f) / gScale - margin); - int endX = (int)(-panX + (width / 2f) / gScale + width + margin); - int endY = (int)(-panY + (height / 2f) / gScale + height + margin); - startX = (startX / gridSize) * gridSize; - startY = (startY / gridSize) * gridSize; - for (int i = startX; i < endX; i += gridSize) { - graphics.fill(i, startY, i + lineW, endY, 0x12FFFFFF); - } - for (int i = startY; i < endY; i += gridSize) { - graphics.fill(startX, i, endX, i + lineW, 0x12FFFFFF); - } - graphics.pose().popPose(); - } - - private boolean wireGeometryMoving() { - return draggingNode != null || draggingSection != null || draggingWireConnIdx >= 0; - } - - private void updateWireInteractionHover(int gx, int gy) { - wireController.updateHover( - graph, - gx, - gy, - editorContentScale(), - editorRevision, - wireGeometryMoving(), - point -> graphPointBlocksWireInteraction((int) point.x(), (int) point.y())); - } - - private boolean graphPointBlocksWireInteraction(int gx, int gy) { - for (WNode n : nodesAtGraphPoint(gx, gy, false)) { - if (gx >= n.getX() - && gx < n.getX() + n.getWidth() - && gy >= n.getY() - && gy < n.getY() + n.getHeight()) { - return true; - } - } - return false; - } - - private void insertWaypointOnConnection(int connIdx, int seg, int ix, int iy) { - WConnection c = graph.getConnections().get(connIdx); - int oldN = c.waypointXs().length; - int[] nxs = new int[oldN + 1]; - int[] nys = new int[oldN + 1]; - for (int i = 0; i < seg; i++) { - nxs[i] = c.waypointXs()[i]; - nys[i] = c.waypointYs()[i]; - } - nxs[seg] = ix; - nys[seg] = iy; - for (int i = seg; i < oldN; i++) { - nxs[i + 1] = c.waypointXs()[i]; - nys[i + 1] = c.waypointYs()[i]; - } - graph.getConnections() - .set( - connIdx, - c.withWaypoints(nxs, nys)); - } - - private void removeWaypointFromConnection(int connIdx, int wpIdx) { - WConnection c = graph.getConnections().get(connIdx); - int n = c.waypointXs().length; - if (wpIdx < 0 || wpIdx >= n || n <= 0) { - return; - } - if (n == 1) { - graph.getConnections() - .set( - connIdx, - c.withWaypoints(new int[0], new int[0])); - return; - } - int[] nxs = new int[n - 1]; - int[] nys = new int[n - 1]; - for (int i = 0, j = 0; i < n; i++) { - if (i == wpIdx) { - continue; - } - nxs[j] = c.waypointXs()[i]; - nys[j] = c.waypointYs()[i]; - j++; - } - graph.getConnections() - .set( - connIdx, - c.withWaypoints(nxs, nys)); - } - - private void renderParticles(GuiGraphics graphics, float deltaTime) { - for (int i = editorParticles.size() - 1; i >= 0; i--) { - NodeParticle p = editorParticles.get(i); - p.x += p.vx * deltaTime * 60.0; p.y += p.vy * deltaTime * 60.0; p.life -= deltaTime * 60.0; - if (p.life <= 0) { editorParticles.remove(i); continue; } - float alpha = (float) p.life / p.maxLife; - int rColor = (p.color & 0xFFFFFF) | ((int)(alpha * 255) << 24); - graphics.fill((int)p.x, (int)p.y, (int)p.x + 2, (int)p.y + 2, rColor); - } - } - - private WNode findNode(UUID id) { - return graph.getNode(id); - } - - private boolean handleContextMenuClick(double mouseX, double mouseY, int button) { - if (contextKind == ContextKind.NONE) return false; - if (button != 0) { contextKind = ContextKind.NONE; return true; } - MenuRect b = currentContextBounds(); - if (!b.contains((int) mouseX, (int) mouseY)) { contextKind = ContextKind.NONE; return true; } - int row = ((int) mouseY - b.y - 2) / 18; - if (contextKind == ContextKind.CANVAS) { - if (row == 0) { - isSearching = true; - searchQuery = ""; - menuFlyoutPath.clear(); - menuAnchorNx = contextAnchorGraphX; - menuAnchorNy = contextAnchorGraphY; - menuX = b.x; - menuY = b.y; - } else if (row == 1) pasteFromClipboard(); - else if (row == 2) beginSectionCreate(contextAnchorGraphX, contextAnchorGraphY); - } else if (contextNode != null) { - if (!contextNode.isSelected()) { - graph.getNodes().forEach(node -> node.setSelected(false)); - contextNode.setSelected(true); - selectedNode = contextNode; - } - if (row == 0) copySelectedNodesToClipboard(); - else if (row == 1) duplicateSelectedNodes(); - else if (row == 2) pasteFromClipboard(); - else if (row == 3) disconnectSelectedNodes(); - else if (row == 4) deleteSelectedNodes(); - } - contextKind = ContextKind.NONE; - return true; - } - - private void openContextMenuAt(int sx, int sy) { - contextAnchorGraphX = screenToGraphX(sx); - contextAnchorGraphY = screenToGraphY(sy); - contextNode = null; - for (WNode node : nodesAtGraphPoint(contextAnchorGraphX, contextAnchorGraphY, true)) { - if (contextAnchorGraphX >= node.getX() && contextAnchorGraphX <= node.getX() + node.getWidth() - && contextAnchorGraphY >= node.getY() && contextAnchorGraphY <= node.getY() + node.getHeight()) { - contextNode = node; - break; - } - } - contextKind = contextNode == null ? ContextKind.CANVAS : ContextKind.NODE; - if (contextNode != null && !contextNode.isSelected()) { - graph.getNodes().forEach(node -> node.setSelected(false)); - contextNode.setSelected(true); - selectedNode = contextNode; - } - } - - @Override - public boolean mouseClicked(double mouseX, double mouseY, int button) { - if (!exportDialogOpen && !importDialogOpen && !newFunctionNamingOpen && !itemPickerOpen) { - if (handleContextMenuClick(mouseX, mouseY, button)) return true; - if (tryHandleNodeDockClick(mouseX, mouseY, button)) return true; - if (button == 0 && categoryRailToggleContains(mouseX, mouseY)) { - categoryRailVisible = !categoryRailVisible; - if (!categoryRailVisible) { - openPaletteCategory = null; - paletteSearchFocused = false; - pendingPaletteNode = null; - paletteDragActivated = false; - } - playUiClick(categoryRailVisible ? 1.02f : 0.96f); - return true; - } - if (handleCategorySidebarClick(mouseX, mouseY, button)) return true; - } - if (exportDialogOpen) { - if (button != 0) { - return true; - } - int boxW = Math.min(540, width - 40); - int boxH = Math.min(220, height - 40); - int bx = width / 2 - boxW / 2; - int by = height / 2 - boxH / 2; - int btnW = 88; - int btnH = 20; - int copyX = bx + boxW - btnW * 2 - 16; - int closeX = bx + boxW - btnW - 8; - int btnY = by + boxH - 30; - if (mouseX >= copyX && mouseX < copyX + btnW && mouseY >= btnY && mouseY < btnY + btnH) { - minecraft.keyboardHandler.setClipboard(exportDialogText); - postShareStatus(false, "gui.computed.share.export_success", exportDialogText.length()); - playUiClick(1.03f); - return true; - } - if (mouseX >= closeX && mouseX < closeX + btnW && mouseY >= btnY && mouseY < btnY + btnH) { - closeExportDialog(); - playUiClick(0.94f); - return true; - } - closeExportDialog(); - playUiClick(0.94f); - return true; - } - if (importDialogOpen) { - if (button != 0) { - return true; - } - int boxW = Math.min(540, width - 40); - int boxH = Math.min(220, height - 40); - int bx = width / 2 - boxW / 2; - int by = height / 2 - boxH / 2; - int tx1 = bx + 8; - int ty1 = by + 24; - int tx2 = bx + boxW - 8; - int ty2 = by + boxH - 44; - int btnW = 88; - int btnH = 20; - int importX = bx + boxW - btnW * 2 - 16; - int cancelX = bx + boxW - btnW - 8; - int btnY = by + boxH - 30; - if (mouseX >= importX && mouseX < importX + btnW && mouseY >= btnY && mouseY < btnY + btnH) { - importGraphFromShareString(importDialogText); - return true; - } - if (mouseX >= cancelX && mouseX < cancelX + btnW && mouseY >= btnY && mouseY < btnY + btnH) { - closeImportFromStringDialog(); - playUiClick(0.94f); - return true; - } - if (mouseX >= tx1 && mouseX < tx2 && mouseY >= ty1 && mouseY < ty2) { - return true; - } - closeImportFromStringDialog(); - playUiClick(0.94f); - return true; - } - if (newFunctionNamingOpen) { - return true; - } - if (itemPickerOpen) { - return handleItemPickerClick(mouseX, mouseY, button); - } - refreshEditorDiagnostics(); - if (diagnosticsIndicatorContains(mouseX, mouseY)) { - if (button == 0) { - diagnosticsPanelOpen = !diagnosticsPanelOpen; - playUiClick(diagnosticsPanelOpen ? 1.02f : 0.96f); - } - return true; - } - if (diagnosticsPanelContains(mouseX, mouseY)) { - return true; - } - if (diagnosticsPanelOpen && button == 0) { - diagnosticsPanelOpen = false; - } - if (handleNestedDiskToolbarClick(mouseX, mouseY, button)) { - return true; - } - if (functionStore != null && button == 0 && schematicBtnContains(mouseX, mouseY)) { - if (functionPickerOpen) { - commitLibraryFunctionRename(); - functionPickerOpen = false; - functionImportSubmenuOpen = false; - } else { - refreshFunctionDiscFileList(); - functionLibraryListScroll = 0; - functionDiscImportListScroll = 0; - functionImportSubmenuOpen = false; - functionPickerOpen = true; - } - playUiClick(1.02f); - return true; - } - if (functionStore != null && functionPickerOpen && button == 0) { - if (functionImportSubmenuOpen && functionImportFlyoutContains(mouseX, mouseY)) { - handleFunctionImportFlyoutClick(mouseX, mouseY); - return true; - } - if (functionPickerPanelContains(mouseX, mouseY)) { - handleFunctionPickerClick(mouseX, mouseY); - return true; - } - commitLibraryFunctionRename(); - functionPickerOpen = false; - functionImportSubmenuOpen = false; - playUiClick(0.98f); - } - if (button == 0 && sectionsToggleContains(mouseX, mouseY)) { - showSectionsSidebar = !showSectionsSidebar; - playUiClick(showSectionsSidebar ? 1.01f : 0.94f); - return true; - } - if (button == 0 && sectionsSidebarContains(mouseX, mouseY)) { - int y = sectionsSidebarY() + 18; - int row = ((int) mouseY - y) / 13; - List sidebarSecs = sectionsSortedByLayer(graph.getSections()); - if (row >= 0 && row < sidebarSecs.size()) { - UUID id = sidebarSecs.get(row).getId(); - selectedSectionId = id; - long now = net.minecraft.Util.getMillis(); - if (id.equals(lastSidebarSectionClickId) && now - lastSidebarSectionClickAtMs <= SECTION_DOUBLE_CLICK_MS) { - for (WGraph.WSection s : graph.getSections()) { - if (s.getId().equals(id)) { - startSectionRename(id, s.getName()); - break; - } - } - } - lastSidebarSectionClickId = id; - lastSidebarSectionClickAtMs = now; - playUiClick(1.0f); - return true; - } - } - if (!isSearching && !isInsideEditorPanel(mouseX, mouseY)) { - onClose(); - return true; - } - if (sectionColorPickerSectionId != null) { - return handleSectionColorPickerMouseClick(mouseX, mouseY, button); - } - if (!isSearching && isInsideEditorPanel(mouseX, mouseY) && !isCanvasPoint(mouseX, mouseY)) { - return true; - } - if (button == 1 && isCanvasPoint(mouseX, mouseY)) { - rightPressX = (int) mouseX; - rightPressY = (int) mouseY; - rightPressAtMs = net.minecraft.Util.getMillis(); - rightDragPanning = false; - contextKind = ContextKind.NONE; - return true; - } - int nx = screenToGraphX(mouseX); - int ny = screenToGraphY(mouseY); - boolean fullDetailInteraction = effectiveDetailLevel() == EditorDetailLevel.FULL; - if (isCreatingSection) { - if (button == 0) { - sectionCreateEndX = nx; - sectionCreateEndY = ny; - return true; - } - if (button == 1 || button == 2) { - isCreatingSection = false; - return true; - } - } - if (isSearching) { - int mx = (int) mouseX; - int my = (int) mouseY; - rebuildSearchHitRows(); - if (searchQuery.trim().isEmpty()) { - layoutBrowseMenuForPointer(mx, my); - } else { - menuFlyoutPath.clear(); - stickyBrowseRootId = null; - clampSearchMenuOnScreen(); - } - net.minecraft.resources.ResourceLocation pick = hitBrowseNodeTypeAt(mx, my); - if (pick != null) { - if (isEditorPeripheralLocked(pick)) { - playUiClick(0.82f); - return true; - } - WNode placed = addNodeAtReturning(pick, menuAnchorNx, menuAnchorNy); - tryAutoConnectPendingOutput(placed); - isSearching = false; - clearStickyBrowseRoot(); - return true; - } - if (menuBrowseContainsMouse(mx, my) || menuSearchFlatContainsMouse(mx, my)) { - return true; - } - isSearching = false; - clearStickyBrowseRoot(); - clearPendingWireSpawn(); - return true; - } - if (button == 0) { - WGraph.WSection selectedSec = null; - if (selectedSectionId != null) { - for (WGraph.WSection s : graph.getSections()) { - if (s.getId().equals(selectedSectionId)) { - selectedSec = s; - break; - } - } - } - if (selectedSec != null) { - SectionResizeHandle rh = hitSectionResizeHandle(selectedSec, nx, ny); - if (rh != SectionResizeHandle.NONE) { - resizingSection = selectedSec; - sectionResizeHandle = rh; - sectionResizeStartX = selectedSec.getX(); - sectionResizeStartY = selectedSec.getY(); - sectionResizeStartW = selectedSec.getWidth(); - sectionResizeStartH = selectedSec.getHeight(); - sectionResizeGrabNx = nx; - sectionResizeGrabNy = ny; - recordCheckpointBeforeEdit(); - playUiClick(0.98f); - return true; - } - } - WGraph.WSection sec = findSectionAt(nx, ny); - if (sec != null) { - long now = net.minecraft.Util.getMillis(); - if (sec.getId().equals(lastSectionHeaderClickId) - && now - lastSectionHeaderClickAtMs <= SECTION_DOUBLE_CLICK_MS) { - selectedSectionId = sec.getId(); - startSectionRename(sec.getId(), sec.getName()); - playUiClick(1.02f); - return true; - } - lastSectionHeaderClickId = sec.getId(); - lastSectionHeaderClickAtMs = now; - draggingSection = sec; - sectionDragOffsetX = nx - sec.getX(); - sectionDragOffsetY = ny - sec.getY(); - sectionDragStartSectionX = sec.getX(); - sectionDragStartSectionY = sec.getY(); - selectedSectionId = sec.getId(); - sectionDragMemberNodes.clear(); - sectionDragOriginalNodePos.clear(); - for (WNode n : graph.getNodes()) { - int cx = n.getX() + n.getWidth() / 2; - int cy = n.getY() + n.getHeight() / 2; - if (cx >= sec.getX() && cx <= sec.getX() + sec.getWidth() - && cy >= sec.getY() && cy <= sec.getY() + sec.getHeight()) { - sectionDragMemberNodes.add(n.getId()); - sectionDragOriginalNodePos.put(n.getId(), new int[] {n.getX(), n.getY()}); - } - } - sectionDragChildSections.clear(); - sectionDragOriginalNestedSectionPos.clear(); - for (WGraph.WSection nested : graph.getSections()) { - if (nested.getId().equals(sec.getId())) { - continue; - } - if (sectionFullyContainedIn(nested, sec)) { - sectionDragChildSections.add(nested); - sectionDragOriginalNestedSectionPos.put( - nested.getId(), new int[] {nested.getX(), nested.getY()}); - } - } - sectionDragPrevTotalDx = 0; - sectionDragPrevTotalDy = 0; - recordCheckpointBeforeEdit(); - return true; - } - } - if (fullDetailInteraction - && button == 0 - && linkingNode == null - && !isCreatingSection - && isInsideEditorPanel(mouseX, mouseY)) { - updateWireInteractionHover(nx, ny); - WireEditorController.Hover wireHover = wireController.hover(); - if (Screen.hasAltDown()) { - if (wireHover.is(WireEditorController.HoverKind.WAYPOINT)) { - recordCheckpointBeforeEdit(); - removeWaypointFromConnection(wireHover.connectionIndex(), wireHover.waypointIndex()); - wireController.clearHover(); - playUiClick(0.78f); - return true; - } - if (wireHover.is(WireEditorController.HoverKind.INSERT_GHOST) - || wireHover.is(WireEditorController.HoverKind.CURVE_ONLY)) { - recordCheckpointBeforeEdit(); - graph.getConnections().remove(wireHover.connectionIndex()); - graph.updateTopology(); - wireController.clearHover(); - playUiClick(0.76f); - return true; - } - } else { - if (wireHover.is(WireEditorController.HoverKind.INSERT_GHOST)) { - int gr = wireController.ghostPickRadius(editorContentScale()); - int ddx = nx - wireHover.insertionX(); - int ddy = ny - wireHover.insertionY(); - if (ddx * ddx + ddy * ddy <= gr * gr) { - recordCheckpointBeforeEdit(); - insertWaypointOnConnection( - wireHover.connectionIndex(), - wireHover.insertionSegment(), - wireHover.insertionX(), - wireHover.insertionY()); - playUiClick(1.04f); - return true; - } - } - if (wireHover.is(WireEditorController.HoverKind.WAYPOINT)) { - recordCheckpointBeforeEdit(); - draggingWireConnIdx = wireHover.connectionIndex(); - draggingWireWaypointIdx = wireHover.waypointIndex(); - return true; - } - } - } - if (button == 1) { - boolean hitAnything = false; - for (WNode node : nodesAtGraphPoint(nx, ny, false)) { - boolean pinHit = fullDetailInteraction - && (node.getPinAt(nx - node.getX(), ny - node.getY(), true) != -1 - || node.getPinAt(nx - node.getX(), ny - node.getY(), false) != -1); - if (pinHit || (nx >= node.getX() && nx <= node.getX() + node.getWidth() && ny >= node.getY() && ny <= node.getY() + node.getHeight())) { - hitAnything = true; break; - } - } - if (!hitAnything && renamingSectionId == null && renamingLibraryFunctionId == null) { - WGraph.WSection secTitle = findSectionAt(nx, ny); - if (secTitle != null) { - openSectionColorPicker(secTitle, (int) mouseX, (int) mouseY); - playUiClick(1.0f); - return true; - } - } - if (!hitAnything) { - clearPendingWireSpawn(); - isSearching = true; - searchQuery = ""; - menuFlyoutPath.clear(); - clearStickyBrowseRoot(); - menuAnchorNx = nx; - menuAnchorNy = ny; - menuX = (int) mouseX; - menuY = (int) mouseY; - return true; - } - } - if (button == 1 && fullDetailInteraction) { - for (WNode node : nodesAtGraphPoint(nx, ny, false)) { - int inPin = node.getPinAt(nx - node.getX(), ny - node.getY(), true); - int outPin = node.getPinAt(nx - node.getX(), ny - node.getY(), false); - if (inPin != -1) { - recordCheckpointBeforeEdit(); - graph.getConnections().removeIf(c -> c.targetNode().equals(node.getId()) && c.targetPin() == inPin); - graph.updateTopology(); - return true; - } - if (outPin != -1) { - recordCheckpointBeforeEdit(); - graph.getConnections().removeIf(c -> c.sourceNode().equals(node.getId()) && c.sourcePin() == outPin); - graph.updateTopology(); - return true; - } - } - } - for (WNode node : nodesAtGraphPoint(nx, ny, true)) { - int outPin = fullDetailInteraction - ? node.getPinAt(nx - node.getX(), ny - node.getY(), false) - : -1; - if (outPin != -1 && !isEditorPeripheralLocked(node.getTypeId())) { - linkingNode = node; - linkingPin = outPin; - return true; - } - if (nx >= node.getX() && nx <= node.getX() + node.getWidth() && ny >= node.getY() && ny <= node.getY() + node.getHeight()) { - if (!Screen.hasShiftDown() && !node.isSelected()) graph.getNodes().forEach(n -> n.setSelected(false)); - node.setSelected(true); - selectedNode = node; // Set before element interaction! - - if (button == 0 && Screen.hasAltDown() && node instanceof FunctionCardNode fh) { - enterFunctionGraphEdit(fh); - return true; - } - - if (fullDetailInteraction && !isEditorPeripheralLocked(node.getTypeId())) { - boolean elementHandled = node.mouseClicked(nx - node.getX(), ny - node.getY(), button); - updateIndexedNode(node); - if (elementHandled) { - return true; - } - } - - recordCheckpointBeforeEdit(); - draggingNode = node; - dragOffsetX = nx - node.getX(); - dragOffsetY = ny - node.getY(); - graph.getNodes().remove(node); - graph.getNodes().add(node); - return true; - } - } - if (button == 0) { - if (!Screen.hasShiftDown()) { - graph.getNodes().forEach(n -> n.setSelected(false)); - } - isSelecting = true; selStartX = nx; selStartY = ny; selEndX = nx; selEndY = ny; - return true; - } - selectedNode = null; if (!Screen.hasShiftDown()) graph.getNodes().forEach(n -> n.setSelected(false)); - return super.mouseClicked(mouseX, mouseY, button); - } - - @Override - public boolean mouseReleased(double mouseX, double mouseY, int button) { - if (button == 0 && pendingPaletteNode != null) { - BrowseNodeRow row = pendingPaletteNode; - pendingPaletteNode = null; - if (paletteDragActivated) { - if (isCanvasPoint(mouseX, mouseY)) { - WNode placed = addNodeAtReturning(row.nodeType(), screenToGraphX(mouseX), screenToGraphY(mouseY)); - if (placed != null) { - graph.getNodes().forEach(node -> node.setSelected(false)); - placed.setSelected(true); - selectedNode = placed; - } - } - } else { - int canvasLeft = viewInset() + paletteWidth(); - int canvasRight = gridRight(); - int canvasTop = viewInset() + TOP_BAR_H; - int canvasBottom = gridBottom(); - WNode placed = addNodeAtReturning( - row.nodeType(), - screenToGraphX((canvasLeft + canvasRight) / 2.0), - screenToGraphY((canvasTop + canvasBottom) / 2.0)); - if (placed != null) { - graph.getNodes().forEach(node -> node.setSelected(false)); - placed.setSelected(true); - selectedNode = placed; - } - } - paletteDragActivated = false; - return true; - } - if (button == 1 && rightPressX >= 0) { - boolean click = !rightDragPanning && PointerGestureClassifier.isContextClick( - rightPressX, rightPressY, rightPressAtMs, - (int) mouseX, (int) mouseY, net.minecraft.Util.getMillis()); - if (click && isCanvasPoint(rightPressX, rightPressY)) openContextMenuAt(rightPressX, rightPressY); - rightPressX = -1; - rightPressY = -1; - rightDragPanning = false; - isPanning = false; - return true; - } - int nx = screenToGraphX(mouseX); - int ny = screenToGraphY(mouseY); - if (isCreatingSection && button == 0) { - sectionCreateEndX = nx; - sectionCreateEndY = ny; - finalizeSectionCreate(); - return true; - } - if (isSelecting) { - float x1 = (float)Math.min(selStartX, selEndX); float y1 = (float)Math.min(selStartY, selEndY); - float x2 = (float)Math.max(selStartX, selEndX); float y2 = (float)Math.max(selStartY, selEndY); - GraphRect selection = new GraphRect(x1, y1, x2, y2); - for (WNode node : nodesIntersectingGraphRect(selection)) { - if (node.getX() + node.getWidth() >= x1 && node.getX() <= x2 && node.getY() + node.getHeight() >= y1 && node.getY() <= y2) node.setSelected(true); - } - isSelecting = false; return true; - } - if (linkingNode != null) { - boolean linked = false; - for (WNode node : nodesAtGraphPoint(nx, ny, false)) { - int inPin = node.getPinAt(nx - node.getX(), ny - node.getY(), true); - if (inPin != -1) { - if (isEditorPeripheralLocked(linkingNode.getTypeId()) - || isEditorPeripheralLocked(node.getTypeId())) { - playUiClick(0.82f); - continue; - } - dev.propulsionteam.computed.internal.node.api.WPin srcPin = linkingNode.getOutputs().get(linkingPin); - dev.propulsionteam.computed.internal.node.api.WPin tgtPin = node.getInputs().get(inPin); - if (srcPin.getDataType() != tgtPin.getDataType() - && !(srcPin.getDataType() == dev.propulsionteam.computed.internal.node.api.WPin.DataType.NUMBER - && tgtPin.getDataType() == dev.propulsionteam.computed.internal.node.api.WPin.DataType.STRING)) { - playUiClick(0.82f); - continue; - } - recordCheckpointBeforeEdit(); - graph.connect(linkingNode.getId(), linkingPin, node.getId(), inPin); - playUiClick(1.1f); - linked = true; - break; - } - } - if (!linked && isInsideEditorPanel(mouseX, mouseY)) { - pendingWireFromNode = linkingNode; - pendingWireFromOutputPin = linkingPin; - pendingWireDragFrozen = true; - pendingWireFrozenTx = nx; - pendingWireFrozenTy = ny; - isSearching = true; - searchQuery = ""; - menuFlyoutPath.clear(); - clearStickyBrowseRoot(); - menuAnchorNx = nx; - menuAnchorNy = ny; - menuX = (int) mouseX; - menuY = (int) mouseY; - } - } - if (effectiveDetailLevel() == EditorDetailLevel.FULL - && selectedNode != null - && !isEditorPeripheralLocked(selectedNode.getTypeId())) { - selectedNode.mouseReleased(nx, ny, button); - updateIndexedNode(selectedNode); - } - isPanning = false; - draggingNode = null; - draggingSection = null; - resizingSection = null; - sectionResizeHandle = SectionResizeHandle.NONE; - linkingNode = null; - linkingPin = -1; - if (button == 0) { - draggingWireConnIdx = -1; - draggingWireWaypointIdx = -1; - } - sectionDragMemberNodes.clear(); - sectionDragOriginalNodePos.clear(); - sectionDragChildSections.clear(); - sectionDragOriginalNestedSectionPos.clear(); - sectionPickDragChannel = -1; - return super.mouseReleased(mouseX, mouseY, button); - } - - @Override - public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) { - if (button == 0 && pendingPaletteNode != null) { - paletteDragActivated |= PointerGestureClassifier.exceededDragThreshold( - paletteDragStartX, paletteDragStartY, (int) mouseX, (int) mouseY); - return true; - } - if (button == 1 && rightPressX >= 0) { - boolean startedNow = false; - if (!rightDragPanning && PointerGestureClassifier.exceededDragThreshold( - rightPressX, rightPressY, (int) mouseX, (int) mouseY)) { - rightDragPanning = true; - isPanning = true; - contextKind = ContextKind.NONE; - startedNow = true; - } - if (rightDragPanning) { - cameraFocusActive = false; - float scale = editorContentScale(); - panX += (startedNow ? mouseX - rightPressX : dragX) / scale; - panY += (startedNow ? mouseY - rightPressY : dragY) / scale; - } - return true; - } - if (sectionColorPickerSectionId != null && sectionPickDragChannel >= 0 && button == 0) { - sectionPickerSetChannelFromMouseX(sectionPickDragChannel, mouseX); - return true; - } - if (isCreatingSection) { - int nx = screenToGraphX(mouseX); - int ny = screenToGraphY(mouseY); - sectionCreateEndX = nx; - sectionCreateEndY = ny; - return true; - } - if (isSelecting) { - float mx = screenToGraphX(mouseX); - float my = screenToGraphY(mouseY); - selEndX = mx; selEndY = my; return true; - } - if (draggingWireConnIdx >= 0 && button == 0) { - if (draggingWireConnIdx >= graph.getConnections().size()) { - draggingWireConnIdx = -1; - draggingWireWaypointIdx = -1; - return super.mouseDragged(mouseX, mouseY, button, dragX, dragY); - } - int gnx = screenToGraphX(mouseX); - int gny = screenToGraphY(mouseY); - WConnection c = graph.getConnections().get(draggingWireConnIdx); - int[] nxs = new int[c.waypointXs().length]; - int[] nys = new int[c.waypointYs().length]; - System.arraycopy(c.waypointXs(), 0, nxs, 0, nxs.length); - System.arraycopy(c.waypointYs(), 0, nys, 0, nys.length); - nxs[draggingWireWaypointIdx] = gnx; - nys[draggingWireWaypointIdx] = gny; - graph.getConnections().set(draggingWireConnIdx, c.withWaypoints(nxs, nys)); - return true; - } - if (isPanning) { - cameraFocusActive = false; - float s = editorContentScale(); - panX += dragX / s; - panY += dragY / s; - return true; - } - if (resizingSection != null && sectionResizeHandle != SectionResizeHandle.NONE) { - int nx = screenToGraphX(mouseX); - int ny = screenToGraphY(mouseY); - int dnx = nx - sectionResizeGrabNx; - int dny = ny - sectionResizeGrabNy; - int x = sectionResizeStartX; - int y = sectionResizeStartY; - int w = sectionResizeStartW; - int h = sectionResizeStartH; - int newX = x; - int newY = y; - int newW = w; - int newH = h; - switch (sectionResizeHandle) { - case E -> newW = Math.max(MIN_SECTION_W, w + dnx); - case S -> newH = Math.max(MIN_SECTION_H, h + dny); - case W -> { - newX = x + dnx; - newW = w - dnx; - if (newW < MIN_SECTION_W) { - newX = x + w - MIN_SECTION_W; - newW = MIN_SECTION_W; - } - } - case SE -> { - newW = Math.max(MIN_SECTION_W, w + dnx); - newH = Math.max(MIN_SECTION_H, h + dny); - } - case SW -> { - newH = Math.max(MIN_SECTION_H, h + dny); - newX = x + dnx; - newW = w - dnx; - if (newW < MIN_SECTION_W) { - newX = x + w - MIN_SECTION_W; - newW = MIN_SECTION_W; - } - } - default -> { - } - } - resizingSection.setPos(newX, newY); - resizingSection.setSize(newW, newH); - return true; - } - if (draggingSection != null) { - int nx = screenToGraphX(mouseX); - int ny = screenToGraphY(mouseY); - int newX = nx - sectionDragOffsetX; - int newY = ny - sectionDragOffsetY; - int totalDx = newX - sectionDragStartSectionX; - int totalDy = newY - sectionDragStartSectionY; - int ddx = totalDx - sectionDragPrevTotalDx; - int ddy = totalDy - sectionDragPrevTotalDy; - sectionDragPrevTotalDx = totalDx; - sectionDragPrevTotalDy = totalDy; - draggingSection.setPos(newX, newY); - for (WGraph.WSection nested : sectionDragChildSections) { - int[] sp = sectionDragOriginalNestedSectionPos.get(nested.getId()); - if (sp != null) { - nested.setPos(sp[0] + totalDx, sp[1] + totalDy); - } - } - for (UUID id : sectionDragMemberNodes) { - WNode n = findNode(id); - int[] p = sectionDragOriginalNodePos.get(id); - if (n != null && p != null) { - n.setPos(p[0] + totalDx, p[1] + totalDy); - updateIndexedNode(n); - } - } - if ((ddx != 0 || ddy != 0) && !sectionDragMemberNodes.isEmpty()) { - graph.shiftWaypointsForConnectionsTouching(sectionDragMemberNodes, ddx, ddy); - } - return true; - } - if (draggingNode != null) { - int idx; - int idy; - if (Screen.hasShiftDown()) { - float s = editorContentScale(); - idx = (int) (dragX / s); - idy = (int) (dragY / s); - } else { - int targetX = screenToGraphX(mouseX) - (int) dragOffsetX; - int targetY = screenToGraphY(mouseY) - (int) dragOffsetY; - idx = targetX - draggingNode.getX(); - idy = targetY - draggingNode.getY(); - } - if (idx != 0 || idy != 0) { - List moved = new ArrayList<>(); - for (WNode n : graph.getNodes()) { - if (n.isSelected()) { - moved.add(n.getId()); - } - } - if (!moved.isEmpty()) { - graph.shiftWaypointsForConnectionsTouching(moved, idx, idy); - } - for (WNode n : graph.getNodes()) { - if (n.isSelected()) { - n.setPos(n.getX() + idx, n.getY() + idy); - updateIndexedNode(n); - } - } - } - return true; - } - return super.mouseDragged(mouseX, mouseY, button, dragX, dragY); - } - - @Override - public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { - if (categoryRailVisible - && mouseX >= viewInset() && mouseX < viewInset() + CATEGORY_RAIL_W - && mouseY >= palettePanelTop() && mouseY < palettePanelBottom()) { - List categories = topPaletteCategories(); - paletteCategoryScroll = Mth.clamp( - paletteCategoryScroll - (int) Math.signum(scrollY), - 0, - maxPaletteCategoryScroll(categories)); - return true; - } - if (openPaletteCategory != null) { - int x = viewInset() + CATEGORY_RAIL_W; - if (mouseX >= x && mouseX < x + CATEGORY_PANEL_W - && mouseY >= palettePanelTop() && mouseY < palettePanelBottom()) { - int visible = Math.max(1, (palettePanelBottom() - (palettePanelTop() + 24)) / CATEGORY_ROW_H); - paletteScroll = Mth.clamp( - paletteScroll - (int) Math.signum(scrollY), - 0, - Math.max(0, paletteRows().size() - visible)); - return true; - } - } - if (importDialogOpen) { - return true; - } - if (!isInsideEditorPanel(mouseX, mouseY)) { - return false; - } - if (itemPickerOpen && itemPickerContains(mouseX, mouseY)) { - int maxScroll = Math.max(0, itemPickCandidates.size() - ITEM_PICK_VISIBLE_ROWS); - itemPickerScroll = - Mth.clamp(itemPickerScroll - (int) Math.signum(scrollY), 0, maxScroll); - return true; - } - if (scrollY != 0) { - cameraFocusActive = false; - } - if (sectionColorPickerSectionId != null) { - return true; - } - if (functionPickerOpen) { - if (functionImportSubmenuOpen && functionImportFlyoutContains(mouseX, mouseY)) { - int nf = functionDiscImportFiles.size(); - if (nf > FUNCTION_LIB_VISIBLE_ROWS) { - functionDiscImportListScroll = - Mth.clamp( - functionDiscImportListScroll - - (int) Math.signum(scrollY), - 0, - nf - FUNCTION_LIB_VISIBLE_ROWS); - } - return true; - } - if (functionPickerDefsViewportContains(mouseX, mouseY) && functionStore != null) { - int n = functionStore.size(); - if (n > FUNCTION_LIB_VISIBLE_ROWS) { - functionLibraryListScroll = - Mth.clamp( - functionLibraryListScroll - (int) Math.signum(scrollY), - 0, - n - FUNCTION_LIB_VISIBLE_ROWS); - } - return true; - } - } - zoom = (float) Math.max(0.1, Math.min(3.0, zoom + scrollY * ZOOM_SCROLL_STEP)); - updateEditorDetailLevel(); - return true; - } - - @Override - public boolean keyPressed(int keyCode, int scanCode, int modifiers) { - if (keyCode == GLFW.GLFW_KEY_ESCAPE && shareMenuOpen) { - shareMenuOpen = false; - return true; - } - if (contextKind != ContextKind.NONE) { - if (keyCode == GLFW.GLFW_KEY_ESCAPE) contextKind = ContextKind.NONE; - return true; - } - if (paletteSearchFocused) { - if (keyCode == GLFW.GLFW_KEY_ESCAPE) paletteSearchFocused = false; - else if (keyCode == GLFW.GLFW_KEY_BACKSPACE && !paletteSearch.isEmpty()) { - paletteSearch = paletteSearch.substring(0, paletteSearch.length() - 1); - paletteScroll = 0; - paletteKeyboardIndex = 0; - } else if (keyCode == GLFW.GLFW_KEY_UP) movePaletteSelection(-1); - else if (keyCode == GLFW.GLFW_KEY_DOWN) movePaletteSelection(1); - else if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) - placePaletteKeyboardSelection(); - return true; - } - if (exportDialogOpen) { - if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - closeExportDialog(); - playUiClick(0.94f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - minecraft.keyboardHandler.setClipboard(exportDialogText); - postShareStatus(false, "gui.computed.share.export_success", exportDialogText.length()); - playUiClick(1.03f); - return true; - } - if (hasControlDown() && keyCode == GLFW.GLFW_KEY_C) { - minecraft.keyboardHandler.setClipboard(exportDialogText); - postShareStatus(false, "gui.computed.share.export_success", exportDialogText.length()); - playUiClick(1.03f); - return true; - } - return true; - } - if (importDialogOpen) { - boolean ctrl = hasControlDown(); - if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - closeImportFromStringDialog(); - playUiClick(0.94f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - importGraphFromShareString(importDialogText); - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_V) { - String clip = minecraft.keyboardHandler.getClipboard(); - if (clip != null && !clip.isEmpty()) { - importDialogText += clip; - playUiClick(1.03f); - } - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_C) { - minecraft.keyboardHandler.setClipboard(importDialogText); - playUiClick(1.01f); - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_A) { - // Keep simple text editor behavior: Ctrl+A mirrors selecting all by copying full text context. - minecraft.keyboardHandler.setClipboard(importDialogText); - playUiClick(0.98f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_BACKSPACE && !importDialogText.isEmpty()) { - importDialogText = importDialogText.substring(0, importDialogText.length() - 1); - playUiClick(0.9f); - return true; - } - return true; - } - if (newFunctionNamingOpen && functionStore != null) { - if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - cancelNewFunctionNaming(); - playUiClick(0.94f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - confirmNewFunctionAfterNaming(); - playUiClick(1.04f); - return true; - } - boolean ctrl = hasControlDown(); - if (keyCode == GLFW.GLFW_KEY_BACKSPACE && !newFunctionNameBuffer.isEmpty()) { - if (ctrl) { - newFunctionNameBuffer = ""; - } else { - newFunctionNameBuffer = newFunctionNameBuffer.substring(0, newFunctionNameBuffer.length() - 1); - } - playUiClick(0.9f); - return true; - } - return true; - } - if (itemPickerOpen) { - if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - closeItemPicker(); - playUiClick(0.92f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_BACKSPACE && !itemPickerQuery.isEmpty()) { - itemPickerQuery = itemPickerQuery.substring(0, itemPickerQuery.length() - 1); - itemPickerScroll = 0; - rebuildItemPickCandidates(); - playUiClick(0.9f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - if (!itemPickCandidates.isEmpty() && itemPickerCallback != null) { - itemPickerCallback.accept(itemPickCandidates.get(0).copyWithCount(1)); - } - closeItemPicker(); - playUiClick(1.03f); - return true; - } - return true; - } - if (tryHandleUndoRedo(keyCode, scanCode)) { - return true; - } - if (sectionColorPickerSectionId != null && keyCode == GLFW.GLFW_KEY_ESCAPE) { - closeSectionColorPicker(); - return true; - } - if (renamingLibraryFunctionId != null) { - return handleLibraryFunctionRenameKey(keyCode, scanCode, modifiers); - } - if (!functionEditStack.isEmpty() && keyCode == GLFW.GLFW_KEY_ESCAPE) { - exitFunctionGraphEdit(); - return true; - } - if (renamingSectionId != null) { - boolean ctrl = hasControlDown(); - boolean shift = Screen.hasShiftDown(); - if (keyCode == GLFW.GLFW_KEY_LEFT_SHIFT - || keyCode == GLFW.GLFW_KEY_RIGHT_SHIFT - || keyCode == GLFW.GLFW_KEY_LEFT_CONTROL - || keyCode == GLFW.GLFW_KEY_RIGHT_CONTROL - || keyCode == GLFW.GLFW_KEY_LEFT_ALT - || keyCode == GLFW.GLFW_KEY_RIGHT_ALT - || keyCode == GLFW.GLFW_KEY_LEFT_SUPER - || keyCode == GLFW.GLFW_KEY_RIGHT_SUPER) { - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_A) { - sectionRenameSelectionPos = 0; - sectionRenameCursor = sectionRenameBuffer.length(); - playUiClick(0.97f); - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_C) { - if (sectionRenameHasSelection()) { - int a = Math.min(sectionRenameCursor, sectionRenameSelectionPos); - int b = Math.max(sectionRenameCursor, sectionRenameSelectionPos); - minecraft.keyboardHandler.setClipboard(sectionRenameBuffer.substring(a, b)); - } else { - minecraft.keyboardHandler.setClipboard(sectionRenameBuffer); - } - playUiClick(1.02f); - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_X) { - if (sectionRenameHasSelection()) { - int a = Math.min(sectionRenameCursor, sectionRenameSelectionPos); - int b = Math.max(sectionRenameCursor, sectionRenameSelectionPos); - minecraft.keyboardHandler.setClipboard(sectionRenameBuffer.substring(a, b)); - sectionRenameDeleteSelection(); - playUiClick(0.9f); - } - return true; - } - if (ctrl && keyCode == GLFW.GLFW_KEY_V) { - String clip = minecraft.keyboardHandler.getClipboard(); - if (clip != null && !clip.isEmpty()) { - sectionRenameReplaceSelection(sectionRenameSanitizePaste(clip)); - playUiClick(1.04f); - } - return true; - } - if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - recordCheckpointBeforeEdit(); - for (WGraph.WSection s : graph.getSections()) { - if (s.getId().equals(renamingSectionId)) { - s.setName(sectionRenameBuffer.trim().isEmpty() ? s.getName() : sectionRenameBuffer.trim()); - break; - } - } - endSectionRenameEditing(); - playUiClick(1.0f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - endSectionRenameEditing(); - return true; - } - if (keyCode == GLFW.GLFW_KEY_BACKSPACE) { - if (sectionRenameHasSelection()) { - sectionRenameDeleteSelection(); - } else if (sectionRenameCursor > 0) { - int start = ctrl ? sectionRenamePreviousWordBoundary(sectionRenameCursor) : sectionRenameCursor - 1; - sectionRenameBuffer = - sectionRenameBuffer.substring(0, start) + sectionRenameBuffer.substring(sectionRenameCursor); - sectionRenameCursor = start; - sectionRenameSelectionPos = sectionRenameCursor; - } - playUiClick(0.9f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_DELETE) { - if (sectionRenameHasSelection()) { - sectionRenameDeleteSelection(); - } else if (sectionRenameCursor < sectionRenameBuffer.length()) { - int end = ctrl ? sectionRenameNextWordBoundary(sectionRenameCursor) : sectionRenameCursor + 1; - sectionRenameBuffer = - sectionRenameBuffer.substring(0, sectionRenameCursor) + sectionRenameBuffer.substring(end); - } - playUiClick(0.9f); - return true; - } - if (keyCode == GLFW.GLFW_KEY_LEFT) { - int next = ctrl ? sectionRenamePreviousWordBoundary(sectionRenameCursor) : Math.max(0, sectionRenameCursor - 1); - sectionRenameMoveCursor(next, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_RIGHT) { - int next = ctrl - ? sectionRenameNextWordBoundary(sectionRenameCursor) - : Math.min(sectionRenameBuffer.length(), sectionRenameCursor + 1); - sectionRenameMoveCursor(next, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_HOME) { - sectionRenameMoveCursor(0, shift); - return true; - } - if (keyCode == GLFW.GLFW_KEY_END) { - sectionRenameMoveCursor(sectionRenameBuffer.length(), shift); - return true; - } - return true; - } - if (isSearching) { - if (keyCode == 256) { - isSearching = false; - clearStickyBrowseRoot(); - clearPendingWireSpawn(); - return true; - } - if (keyCode == 257 || keyCode == 335) { - rebuildSearchHitRows(); - if (!searchQuery.trim().isEmpty() && !searchHitRows.isEmpty()) { - boolean placedAny = false; - for (BrowseNodeRow row : searchHitRows) { - if (!isEditorPeripheralLocked(row.nodeType())) { - WNode placed = addNodeAtReturning(row.nodeType(), menuAnchorNx, menuAnchorNy); - tryAutoConnectPendingOutput(placed); - isSearching = false; - clearStickyBrowseRoot(); - placedAny = true; - break; - } - } - if (!placedAny) { - playUiClick(0.82f); - } - } - return true; - } - if (keyCode == 259) { - if (!searchQuery.isEmpty()) { - searchQuery = searchQuery.substring(0, searchQuery.length() - 1); - } else if (!menuFlyoutPath.isEmpty()) { - menuFlyoutPath.remove(menuFlyoutPath.size() - 1); - } - return true; - } - return true; - } - if (effectiveDetailLevel() == EditorDetailLevel.FULL - && selectedNode != null - && !isEditorPeripheralLocked(selectedNode.getTypeId()) - && selectedNode.keyPressed(keyCode, scanCode, modifiers)) { - return true; - } - boolean nodeUiFocused = - effectiveDetailLevel() == EditorDetailLevel.FULL - && selectedNode != null - && !isEditorPeripheralLocked(selectedNode.getTypeId()) - && selectedNode.hasFocusedElement(); - if (!nodeUiFocused && keyCode == GLFW.GLFW_KEY_S && hasControlDown()) { - int nx = screenToGraphX(this.mouseX); - int ny = screenToGraphY(this.mouseY); - beginSectionCreate(nx, ny); - playUiClick(1.02f); - return true; - } - if (!nodeUiFocused && keyCode == GLFW.GLFW_KEY_F2 && selectedSectionId != null) { - for (WGraph.WSection s : graph.getSections()) { - if (s.getId().equals(selectedSectionId)) { - startSectionRename(s.getId(), s.getName()); - return true; - } - } - } - if (!nodeUiFocused - && keyCode == GLFW.GLFW_KEY_F2 - && selectedLibraryFunctionId != null - && functionStore != null) { - FunctionDefinitionStore.Definition lf = functionStore.get(selectedLibraryFunctionId); - if (lf != null && !isFunctionLibraryDefinitionHardwareLocked(lf)) { - startLibraryFunctionRename(lf.id(), lf.name()); - functionPickerOpen = true; - return true; - } - } - boolean plainDeleteNoMods = - !hasControlDown() && (keyCode == GLFW.GLFW_KEY_DELETE || keyCode == GLFW.GLFW_KEY_X); - if (plainDeleteNoMods && !nodeUiFocused && renamingSectionId == null && renamingLibraryFunctionId == null) { - if (anyNodeSelectedForDock()) { - deleteSelectedNodes(); - return true; - } - if (selectedSectionId != null) { - deleteSectionAndMembers(selectedSectionId); - return true; - } - if (selectedNode != null) { - recordCheckpointBeforeEdit(); - graph.removeNode(selectedNode); - selectedNode = null; - return true; - } - } - if (hasControlDown() - && keyCode == GLFW.GLFW_KEY_X - && !nodeUiFocused - && renamingSectionId == null - && renamingLibraryFunctionId == null) { - if (anyNodeSelectedForDock()) { - copySelectedNodesToClipboard(); - deleteSelectedNodes(); - return true; - } - if (selectedSectionId != null) { - UUID sid = selectedSectionId; - copySectionBundle(sid); - deleteSectionAndMembers(sid); - return true; - } - } - if (keyCode == 65 && hasControlDown()) { graph.getNodes().forEach(n -> n.setSelected(true)); return true; } - if (keyCode == 67 && hasControlDown()) { copySelected(); return true; } - if (keyCode == 86 && hasControlDown()) { pasteFromClipboard(); return true; } - if (keyCode == 65 && Screen.hasShiftDown()) { - clearPendingWireSpawn(); - isSearching = true; - searchQuery = ""; - menuFlyoutPath.clear(); - clearStickyBrowseRoot(); - menuAnchorNx = screenToGraphX(this.mouseX); - menuAnchorNy = screenToGraphY(this.mouseY); - menuX = this.mouseX; - menuY = this.mouseY; - return true; - } - return super.keyPressed(keyCode, scanCode, modifiers); - } - - @Override - public boolean charTyped(char codePoint, int modifiers) { - if (paletteSearchFocused) { - if (!Character.isISOControl(codePoint) && paletteSearch.length() < 64) { - paletteSearch += codePoint; - paletteScroll = 0; - paletteKeyboardIndex = 0; - } - return true; - } - if (importDialogOpen) { - if (!Character.isISOControl(codePoint)) { - importDialogText += codePoint; - } - return true; - } - if (newFunctionNamingOpen && functionStore != null) { - if (!Character.isISOControl(codePoint) && newFunctionNameBuffer.length() < 48) { - newFunctionNameBuffer += codePoint; - } - return true; - } - if (itemPickerOpen) { - if (!Character.isISOControl(codePoint) && itemPickerQuery.length() < 64) { - itemPickerQuery += codePoint; - itemPickerScroll = 0; - rebuildItemPickCandidates(); - } - return true; - } - if (renamingLibraryFunctionId != null) { - if (!Character.isISOControl(codePoint)) { - libraryFnRenameReplaceSelection(String.valueOf(codePoint)); - } - return true; - } - if (renamingSectionId != null) { - if (!Character.isISOControl(codePoint)) { - sectionRenameReplaceSelection(String.valueOf(codePoint)); - } - return true; - } - if (isSearching) { - if (searchQuery.isEmpty() && (codePoint == 'a' || codePoint == 'A' || codePoint == 'ф' || codePoint == 'Ф')) return true; - searchQuery += codePoint; - return true; - } - if (effectiveDetailLevel() == EditorDetailLevel.FULL - && selectedNode != null - && !isEditorPeripheralLocked(selectedNode.getTypeId()) - && selectedNode.charTyped(codePoint, modifiers)) { - return true; - } - return super.charTyped(codePoint, modifiers); - } - - private WNode addNodeAtReturning(net.minecraft.resources.ResourceLocation type, int x, int y) { - if (SECTION_TOOL_TYPE.equals(type)) { - beginSectionCreate(x, y); - playUiClick(1.02f); - return null; - } - if (isEditorPeripheralLocked(type)) { - playUiClick(0.82f); - return null; - } - WNode node = NodeRegistry.createNode(type, x, y); - if (node != null) { - recordCheckpointBeforeEdit(); - graph.addNode(node); - playUiClick(1.05f); - } - return node; - } - - private void clearPendingWireSpawn() { - pendingWireFromNode = null; - pendingWireFromOutputPin = -1; - pendingWireDragFrozen = false; - } - - /** Connect pending output wire to {@code newNode}'s first input, if any. */ - private void tryAutoConnectPendingOutput(WNode newNode) { - if (pendingWireFromNode == null || pendingWireFromOutputPin < 0) { - return; - } - try { - if (newNode != null && !newNode.getInputs().isEmpty()) { - recordCheckpointBeforeEdit(); - graph.getConnections() - .removeIf(c -> c.targetNode().equals(newNode.getId()) && c.targetPin() == 0); - graph.connect( - pendingWireFromNode.getId(), - pendingWireFromOutputPin, - newNode.getId(), - 0); - } - } finally { - clearPendingWireSpawn(); - } - } - - private static boolean dockButtonHovered(int mx, int my, int bx, int by, int btn) { - return mx >= bx && mx < bx + btn && my >= by && my < by + btn; - } - - private ActionDockLayout actionDockLayout() { - List buttons = new ArrayList<>(); - buttons.add(ActionButton.SHARE); - buttons.add(ActionButton.CENTER); - if (anyNodeSelectedForDock()) { - buttons.add(ActionButton.DUPLICATE); - buttons.add(ActionButton.DISCONNECT); - } - buttons.add(ActionButton.DELETE); - int width = buttons.size() * ACTION_BUTTON_SIZE + (buttons.size() - 1) * ACTION_BUTTON_GAP; - return new ActionDockLayout( - this.width / 2 - width / 2, - height - viewInset() - ACTION_BUTTON_SIZE - 6, - width, - List.copyOf(buttons)); - } - - private int actionButtonX(ActionDockLayout layout, int index) { - return layout.x() + index * (ACTION_BUTTON_SIZE + ACTION_BUTTON_GAP); - } - - private ActionButton hoveredActionButton(ActionDockLayout layout, int mx, int my) { - for (int i = 0; i < layout.buttons().size(); i++) { - if (dockButtonHovered(mx, my, actionButtonX(layout, i), layout.y(), ACTION_BUTTON_SIZE)) { - return layout.buttons().get(i); - } - } - return null; - } - - private void renderNodeActionDock(GuiGraphics graphics, int mx, int my, float ease) { - ActionDockLayout layout = actionDockLayout(); - boolean selected = anyNodeSelectedForDock(); - ActionButton hovered = hoveredActionButton(layout, mx, my); - int iconOffset = (ACTION_BUTTON_SIZE - ICON_SIZE) / 2; - for (int i = 0; i < layout.buttons().size(); i++) { - ActionButton action = layout.buttons().get(i); - int x = actionButtonX(layout, i); - boolean enabled = action != ActionButton.DELETE || selected; - if (action == ActionButton.DELETE && enabled) { - ComputedEditorStyle.drawDangerButton(graphics, x, layout.y(), ACTION_BUTTON_SIZE, ACTION_BUTTON_SIZE, - hovered == action); - } else { - ComputedEditorStyle.drawButton(graphics, x, layout.y(), ACTION_BUTTON_SIZE, ACTION_BUTTON_SIZE, - enabled && hovered == action, action == ActionButton.SHARE && shareMenuOpen); - } - int color = enabled ? ComputedEditorTheme.TEXT_SECONDARY : ComputedEditorTheme.TEXT_DISABLED; - int ix = x + iconOffset; - int iy = layout.y() + iconOffset; - switch (action) { - case SHARE -> ComputedEditorIcons.drawImportExport(graphics, ix + 1, iy + 1, color); - case CENTER -> ComputedEditorIcons.drawCenterView(graphics, ix + 1, iy + 1, color); - case DELETE -> ComputedEditorIcons.drawTrash( - graphics, ix + 1, iy + 1, - enabled ? ComputedEditorTheme.STATUS_ERROR_TEXT : ComputedEditorTheme.TEXT_DISABLED); - case DUPLICATE, DISCONNECT -> { - ComputedEditorStyle.beginTextureIcon(graphics); - ResourceLocation icon = action == ActionButton.DUPLICATE ? ICON_DUPLICATE : ICON_DISCONNECT; - graphics.blit(icon, ix, iy, 0, 0, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE); - } - } - } - if (shareMenuOpen) { - int shareX = actionButtonX(layout, 0); - int menuY = layout.y() - 42; - ComputedEditorStyle.drawMenuPanel(graphics, shareX, menuY, 112, 40); - boolean exportHovered = mx >= shareX && mx < shareX + 112 && my >= menuY + 2 && my < menuY + 20; - boolean importHovered = mx >= shareX && mx < shareX + 112 && my >= menuY + 20 && my < menuY + 38; - ComputedEditorStyle.drawMenuRow(graphics, shareX + 1, menuY + 2, 110, 18, exportHovered, false); - ComputedEditorStyle.drawMenuRow(graphics, shareX + 1, menuY + 20, 110, 18, importHovered, false); - graphics.drawString(font, "Export program", shareX + 7, menuY + 7, ComputedEditorTheme.TEXT_PRIMARY, false); - graphics.drawString(font, "Import program", shareX + 7, menuY + 25, ComputedEditorTheme.TEXT_PRIMARY, false); - } - if (hovered != null) { - String label = switch (hovered) { - case SHARE -> "Import / Export"; - case CENTER -> "Center View"; - case DUPLICATE -> "Duplicate Selected"; - case DISCONNECT -> "Disconnect Selected"; - case DELETE -> selected ? "Delete Selected" : "Delete Selected (nothing selected)"; - }; - queueEditorTooltip(Component.literal(label), mx, my); - } - } - - private boolean tryHandleNodeDockClick(double mouseX, double mouseY, int button) { - if (isSearching || button != 0) return false; - ActionDockLayout layout = actionDockLayout(); - int mx = (int) mouseX; - int my = (int) mouseY; - int shareX = actionButtonX(layout, 0); - int shareMenuY = layout.y() - 42; - if (shareMenuOpen && mx >= shareX && mx < shareX + 112 - && my >= shareMenuY + 2 && my < shareMenuY + 38) { - if (my < shareMenuY + 20) openExportDialog(); else openImportFromStringDialog(); - shareMenuOpen = false; - return true; - } - ActionButton action = hoveredActionButton(layout, mx, my); - if (action == null) { - if (shareMenuOpen) { shareMenuOpen = false; return true; } - return false; - } - switch (action) { - case SHARE -> shareMenuOpen = !shareMenuOpen; - case CENTER -> requestCameraCenterOnNodes(); - case DUPLICATE -> duplicateSelectedNodes(); - case DISCONNECT -> disconnectSelectedNodes(); - case DELETE -> { if (anyNodeSelectedForDock()) deleteSelectedNodes(); } - } - return true; - } - - private void duplicateSelectedNodes() { - List sel = new ArrayList<>(); - for (WNode n : graph.getNodes()) { - if (n.isSelected()) { - sel.add(n); - } - } - if (sel.isEmpty()) { - return; - } - recordCheckpointBeforeEdit(); - playUiClick(1.08f); - final int dx = 24; - final int dy = 24; - graph.getNodes().forEach(n -> n.setSelected(false)); - WNode last = null; - for (WNode src : sel) { - if (src.isDuplicationLocked()) { - continue; - } - CompoundTag t = src.save().copy(); - t.remove("id"); - t.putInt("x", src.getX() + dx); - t.putInt("y", src.getY() + dy); - net.minecraft.resources.ResourceLocation type = - net.minecraft.resources.ResourceLocation.parse(t.getString("typeId")); - WNode copy = NodeRegistry.createNode(type, t.getInt("x"), t.getInt("y")); - if (copy != null && !isEditorPeripheralLocked(type)) { - copy.load(t); - graph.addNode(copy); - copy.setSelected(true); - last = copy; - } - } - selectedNode = last; - } - - private void deleteSelectedNodes() { - List rm = new ArrayList<>(); - for (WNode n : graph.getNodes()) { - if (n.isSelected() && !n.isDeletionLocked()) { - rm.add(n); - } - } - if (rm.isEmpty()) { - return; - } - recordCheckpointBeforeEdit(); - playUiClick(0.92f); - for (WNode n : rm) { - graph.removeNode(n); - } - selectedNode = null; - } - - private void disconnectSelectedNodes() { - Set ids = new HashSet<>(); - for (WNode n : graph.getNodes()) { - if (n.isSelected()) { - ids.add(n.getId()); - } - } - if (!ids.isEmpty()) { - recordCheckpointBeforeEdit(); - playUiClick(0.98f); - } - graph.disconnectNodes(ids); - } - - private static boolean nodeCenterInsideSection(WNode n, WGraph.WSection s) { - int cx = n.getX() + n.getWidth() / 2; - int cy = n.getY() + n.getHeight() / 2; - return cx >= s.getX() - && cx <= s.getX() + s.getWidth() - && cy >= s.getY() - && cy <= s.getY() + s.getHeight(); - } - - private void deleteSectionAndMembers(UUID sectionId) { - WGraph.WSection sec = null; - for (WGraph.WSection s : graph.getSections()) { - if (s.getId().equals(sectionId)) { - sec = s; - break; - } - } - if (sec == null) { - return; - } - recordCheckpointBeforeEdit(); - List rm = new ArrayList<>(); - for (WNode n : graph.getNodes()) { - if (nodeCenterInsideSection(n, sec) && !n.isDeletionLocked()) { - rm.add(n); - } - } - for (WNode n : rm) { - graph.removeNode(n); - } - List removeSectionIds = new ArrayList<>(); - removeSectionIds.add(sectionId); - for (WGraph.WSection s : graph.getSections()) { - if (!s.getId().equals(sectionId) && sectionFullyContainedIn(s, sec)) { - removeSectionIds.add(s.getId()); - } - } - graph.getSections().removeIf(s -> removeSectionIds.contains(s.getId())); - selectedSectionId = null; - if (sectionColorPickerSectionId != null && removeSectionIds.contains(sectionColorPickerSectionId)) { - closeSectionColorPicker(); - } - selectedNode = null; - playUiClick(0.92f); - } - - private void copySectionBundle(UUID sectionId) { - WGraph.WSection sec = null; - for (WGraph.WSection s : graph.getSections()) { - if (s.getId().equals(sectionId)) { - sec = s; - break; - } - } - if (sec == null) { - return; - } - List inside = new ArrayList<>(); - ListTag nodesTag = new ListTag(); - for (WNode node : graph.getNodes()) { - if (nodeCenterInsideSection(node, sec)) { - if (node.isDuplicationLocked()) { - continue; - } - inside.add(node); - nodesTag.add(node.save()); - } - } - Set insideIds = new HashSet<>(); - for (WNode n : inside) { - insideIds.add(n.getId()); - } - ListTag connTag = new ListTag(); - for (WConnection conn : graph.getConnections()) { - if (!insideIds.contains(conn.sourceNode()) || !insideIds.contains(conn.targetNode())) { - continue; - } - CompoundTag c = new CompoundTag(); - c.putString("src", conn.sourceNode().toString()); - c.putInt("srcP", conn.sourcePin()); - c.putString("tgt", conn.targetNode().toString()); - c.putInt("tgtP", conn.targetPin()); - if (conn.waypointXs().length > 0) { - ListTag wps = new ListTag(); - for (int j = 0; j < conn.waypointXs().length; j++) { - CompoundTag w = new CompoundTag(); - w.putInt("x", conn.waypointXs()[j]); - w.putInt("y", conn.waypointYs()[j]); - wps.add(w); - } - c.put("wps", wps); - } - connTag.add(c); - } - ListTag sectionsTag = new ListTag(); - sectionsTag.add(sec.toNbt()); - List nested = new ArrayList<>(); - for (WGraph.WSection s : graph.getSections()) { - if (s.getId().equals(sec.getId())) { - continue; - } - if (sectionFullyContainedIn(s, sec)) { - nested.add(s); - } - } - nested.sort(Comparator.comparingInt(WGraph.WSection::getLayer)); - for (WGraph.WSection s : nested) { - sectionsTag.add(s.toNbt()); - } - CompoundTag root = new CompoundTag(); - root.put("nodes", nodesTag); - root.put("conns", connTag); - root.put("sections", sectionsTag); - root.putBoolean("computedSectionClipboard", true); - minecraft.keyboardHandler.setClipboard(encodeClipboardGraph(root)); - playUiClick(1.03f); - } - - private void copySelected() { - boolean anyNodes = false; - for (WNode n : graph.getNodes()) { - if (n.isSelected()) { - anyNodes = true; - break; - } - } - if (anyNodes) { - copySelectedNodesToClipboard(); - return; - } - if (selectedSectionId != null) { - copySectionBundle(selectedSectionId); - } - } - - private void copySelectedNodesToClipboard() { - ListTag nodesTag = new ListTag(); - for (WNode node : graph.getNodes()) { - if (node.isSelected() && !node.isDuplicationLocked()) { - nodesTag.add(node.save()); - } - } - if (nodesTag.isEmpty()) { - return; - } - CompoundTag root = new CompoundTag(); - root.put("nodes", nodesTag); - ListTag connTag = new ListTag(); - for (WConnection conn : graph.getConnections()) { - WNode src = findNode(conn.sourceNode()); - WNode tgt = findNode(conn.targetNode()); - if (src != null && tgt != null && src.isSelected() && tgt.isSelected()) { - CompoundTag c = new CompoundTag(); - c.putString("src", conn.sourceNode().toString()); - c.putInt("srcP", conn.sourcePin()); - c.putString("tgt", conn.targetNode().toString()); - c.putInt("tgtP", conn.targetPin()); - if (conn.waypointXs().length > 0) { - ListTag wps = new ListTag(); - for (int j = 0; j < conn.waypointXs().length; j++) { - CompoundTag w = new CompoundTag(); - w.putInt("x", conn.waypointXs()[j]); - w.putInt("y", conn.waypointYs()[j]); - wps.add(w); - } - c.put("wps", wps); - } - connTag.add(c); - } - } - root.put("conns", connTag); - minecraft.keyboardHandler.setClipboard(encodeClipboardGraph(root)); - playUiClick(1.03f); - } - - private void pasteFromClipboard() { - String data = minecraft.keyboardHandler.getClipboard(); if (data == null || data.isEmpty()) return; - try { - String decoded = new String(Base64.getDecoder().decode(data)); - CompoundTag encodedRoot = TagParser.parseTag(decoded); - CompoundTag root = decodeClipboardGraph(encodedRoot); - ListTag nodesTag = root.getList("nodes", 10); - ListTag sectionsClipboard = root.getList("sections", 10); - if (nodesTag.isEmpty() && sectionsClipboard.isEmpty()) { - return; - } - recordCheckpointBeforeEdit(); - Map oldToNew = new HashMap<>(); - graph.getNodes().forEach(n -> n.setSelected(false)); - for (int i = 0; i < sectionsClipboard.size(); i++) { - CompoundTag st = sectionsClipboard.getCompound(i).copy(); - st.remove("id"); - st.putInt("x", st.getInt("x") + 10); - st.putInt("y", st.getInt("y") + 10); - graph.getSections().add(WGraph.WSection.fromNbt(st)); - } - for (int i = 0; i < nodesTag.size(); i++) { - CompoundTag raw = nodesTag.getCompound(i); - UUID oldId = UUID.fromString(raw.getString("id")); - CompoundTag nTag = raw.copy(); - nTag.remove("id"); - net.minecraft.resources.ResourceLocation type = - net.minecraft.resources.ResourceLocation.parse(nTag.getString("typeId")); - if (FunctionStartNode.TYPE_FN_START.equals(type) || FunctionEndNode.TYPE_FN_END.equals(type)) { - continue; - } - WNode newNode = NodeRegistry.createNode(type, nTag.getInt("x") + 10, nTag.getInt("y") + 10); - if (newNode == null) { - newNode = dev.propulsionteam.computed.internal.node.MissingNode.fromLegacyTag(type, nTag); - } - if (newNode != null && !isEditorPeripheralLocked(type)) { - newNode.load(nTag); - oldToNew.put(oldId, newNode.getId()); - graph.addNode(newNode); - newNode.setSelected(true); - } - } - ListTag connTag = root.getList("conns", 10); - for (int i = 0; i < connTag.size(); i++) { - CompoundTag c = connTag.getCompound(i); - UUID newSrc = oldToNew.get(UUID.fromString(c.getString("src"))); - UUID newTgt = oldToNew.get(UUID.fromString(c.getString("tgt"))); - if (newSrc == null || newTgt == null) { - continue; - } - if (c.contains("wps")) { - ListTag wps = c.getList("wps", 10); - int[] wx = new int[wps.size()]; - int[] wy = new int[wps.size()]; - for (int j = 0; j < wps.size(); j++) { - CompoundTag w = wps.getCompound(j); - wx[j] = w.getInt("x") + 10; - wy[j] = w.getInt("y") + 10; - } - graph.connect( - new WConnection(newSrc, c.getInt("srcP"), newTgt, c.getInt("tgtP"), wx, wy)); - } else { - graph.connect(newSrc, c.getInt("srcP"), newTgt, c.getInt("tgtP")); - } - } - if (!oldToNew.isEmpty() || !sectionsClipboard.isEmpty()) { - playUiClick(1.07f); - } - } catch (Exception e) {} - } - - private static String encodeClipboardGraph(CompoundTag legacyGraph) { - var program = ProgramCodec.decode(legacyGraph, ProgramBridge::isKnownNodeType).program(); - CompoundTag encoded = ProgramCodec.write(program); - return Base64.getEncoder().encodeToString( - encoded.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } - - private static CompoundTag decodeClipboardGraph(CompoundTag encoded) { - if (!encoded.contains("formatVersion") && !encoded.contains(ProgramBridge.PROGRAM_TAG)) { - return encoded; + return "Canvas edit"; } - var program = ProgramCodec.decode(encoded, ProgramBridge::isKnownNodeType).program(); - return ProgramCodec.toLegacyBundleTag(program).getCompound("ComputerGraph"); } } diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodeCategories.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodeCategories.java deleted file mode 100644 index 9a5ea1a..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodeCategories.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class BuiltinNodeCategories { - public static final ResourceLocation MATH = BuiltinNodeIds.of("menu_math"); - public static final ResourceLocation MATH_BINARY = BuiltinNodeIds.of("menu_math_binary"); - public static final ResourceLocation MATH_UNARY = BuiltinNodeIds.of("menu_math_unary"); - public static final ResourceLocation MATH_TRIG = BuiltinNodeIds.of("menu_math_trig"); - public static final ResourceLocation SOURCES = BuiltinNodeIds.of("menu_sources"); - public static final ResourceLocation IO = BuiltinNodeIds.of("menu_io"); - public static final ResourceLocation VISUALS = BuiltinNodeIds.of("menu_visuals"); - public static final ResourceLocation ORGANIZATION = BuiltinNodeIds.of("menu_organization"); - public static final ResourceLocation LOGIC = BuiltinNodeIds.of("menu_logic"); - public static final ResourceLocation LOGIC_BINARY = BuiltinNodeIds.of("menu_logic_binary"); - public static final ResourceLocation LOGIC_UNARY = BuiltinNodeIds.of("menu_logic_unary"); - public static final ResourceLocation LOGIC_COMPARISON = BuiltinNodeIds.of("menu_logic_comparison"); - public static final ResourceLocation LOGIC_MEMORY = BuiltinNodeIds.of("menu_logic_memory"); - - /** Sentinel: nodes whose MENU is this are hidden from the add menu. */ - public static final ResourceLocation HIDDEN = BuiltinNodeIds.of("menu_hidden_sentinel"); - - private BuiltinNodeCategories() {} - - public static void registerAll() { - NodeMenuRegistry.registerCategory(MATH, Component.literal("Math"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(MATH_BINARY, Component.literal("Binary"), MATH); - NodeMenuRegistry.registerCategory(MATH_UNARY, Component.literal("Unary & rounding"), MATH); - NodeMenuRegistry.registerCategory(MATH_TRIG, Component.literal("Trig"), MATH); - NodeMenuRegistry.registerCategory(SOURCES, Component.literal("Sources"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(IO, Component.literal("I/O"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(VISUALS, Component.literal("Visuals"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(ORGANIZATION, Component.literal("Organization"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(LOGIC, Component.literal("Logic"), NodeMenuRegistry.ROOT); - NodeMenuRegistry.registerCategory(LOGIC_BINARY, Component.literal("Binary"), LOGIC); - NodeMenuRegistry.registerCategory(LOGIC_UNARY, Component.literal("Unary"), LOGIC); - NodeMenuRegistry.registerCategory(LOGIC_COMPARISON, Component.literal("Comparison"), LOGIC); - NodeMenuRegistry.registerCategory(LOGIC_MEMORY, Component.literal("Memory"), LOGIC); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodeIds.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodeIds.java deleted file mode 100644 index fb6acc9..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodeIds.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal; - -import net.minecraft.resources.ResourceLocation; - -public final class BuiltinNodeIds { - private BuiltinNodeIds() {} - - public static ResourceLocation of(String path) { - return ResourceLocation.fromNamespaceAndPath("computed", path); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodes.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodes.java deleted file mode 100644 index 4ee5108..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/BuiltinNodes.java +++ /dev/null @@ -1,191 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal; - -import dev.propulsionteam.computed.internal.node.api.CounterNode; -import dev.propulsionteam.computed.internal.node.api.FunctionCardNode; -import dev.propulsionteam.computed.internal.node.api.FunctionEndNode; -import dev.propulsionteam.computed.internal.node.api.FunctionStartNode; -import dev.propulsionteam.computed.internal.node.api.MuxNode; -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.PassOnNthRisingEdgeNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.io.BoolToLevelNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.io.DisplayNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.io.LevelToBoolNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.AndNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.EdgeFallNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.EdgeRiseNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.NandNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.NorNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.OrNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.SchmittNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.XnorNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary.XorNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison.ApproxNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison.EqualNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison.GreaterEqualNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison.GreaterThanNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison.LessEqualNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison.LessThanNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.memory.DFlipFlopNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.memory.SrLatchNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.logic.unary.NotNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.AddNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.ClampNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.DivideNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.LerpNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.MapNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.MaxNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.MinNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.ModuloNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.MultiplyNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.PowerNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.SubtractNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.trig.Atan2Node; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.trig.CosNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.trig.SinNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.trig.TanNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.AbsNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.AverageNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.CeilNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.ExpNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.FloorNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.Log10Node; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.LogNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.NegateNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.QuantizeRedstoneNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.RandomNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.RoundNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.SignNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.SqrtNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.sources.ConstantNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.sources.DelayNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.sources.OscillatorNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.sources.PulseNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.sources.SampleHoldNode; -import dev.propulsionteam.computed.internal.node.internal.nodes.sources.TickNode; -import net.minecraft.network.chat.Component; -import net.neoforged.fml.loading.FMLEnvironment; - -/** https://github.com/webyep-art/webs_node_lib (MIT, webyep). */ -public final class BuiltinNodes { - - private BuiltinNodes() {} - - public static void register() { - BuiltinNodeCategories.registerAll(); - - // math/binary - AddNode.register(); - SubtractNode.register(); - MultiplyNode.register(); - DivideNode.register(); - ModuloNode.register(); - MinNode.register(); - MaxNode.register(); - PowerNode.register(); - - // math/unary - AbsNode.register(); - SqrtNode.register(); - FloorNode.register(); - CeilNode.register(); - RoundNode.register(); - NegateNode.register(); - LogNode.register(); - Log10Node.register(); - ExpNode.register(); - SignNode.register(); - RandomNode.register(); - - // math/trig - SinNode.register(); - CosNode.register(); - TanNode.register(); - Atan2Node.register(); - - // sources (first batch — preserves original menu order) - ConstantNode.register(); - TickNode.register(); - PulseNode.register(); - OscillatorNode.register(); - CounterNode.register(); - PassOnNthRisingEdgeNode.register(); - - // i/o - DisplayNode.register(); - - // visuals (client-only editor nodes; keep out of dedicated-server registry) - registerClientVisualNodes(); - - // organization (tool_section is editor-only — menu entry without NodeRegistry) - NodeMenuRegistry.addNodeEntry( - BuiltinNodeCategories.ORGANIZATION, - BuiltinNodeIds.of("tool_section"), - Component.literal("Section")); - - // logic/unary - NotNode.register(); - - // logic/binary - AndNode.register(); - OrNode.register(); - XorNode.register(); - NandNode.register(); - NorNode.register(); - XnorNode.register(); - - // logic/comparison - EqualNode.register(); - GreaterThanNode.register(); - LessThanNode.register(); - GreaterEqualNode.register(); - LessEqualNode.register(); - ApproxNode.register(); - - // logic/binary (extended) - EdgeRiseNode.register(); - EdgeFallNode.register(); - SchmittNode.register(); - MuxNode.register(); - - // logic/memory - SrLatchNode.register(); - DFlipFlopNode.register(); - - // math (late additions — original menu order) - ClampNode.register(); - MapNode.register(); - LerpNode.register(); - AverageNode.register(); - QuantizeRedstoneNode.register(); - - // sources (late additions) - DelayNode.register(); - SampleHoldNode.register(); - - // i/o (late additions) - BoolToLevelNode.register(); - LevelToBoolNode.register(); - - // hidden — function nodes are placed from the schematic picker, not the add menu - FunctionStartNode.register(); - FunctionEndNode.register(); - FunctionCardNode.register(); - } - - private static void registerClientVisualNodes() { - if (FMLEnvironment.dist.isDedicatedServer()) { - return; - } - registerNodeClass("dev.propulsionteam.computed.internal.node.internal.nodes.visuals.Viewport3DNode"); - registerNodeClass("dev.propulsionteam.computed.internal.node.internal.nodes.visuals.RgbPreviewNode"); - } - - private static void registerNodeClass(String className) { - try { - Class nodeClass = Class.forName(className); - nodeClass.getMethod("register").invoke(null); - } catch (ReflectiveOperationException e) { - throw new RuntimeException("Failed to register node class: " + className, e); - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/ComputedNodeCommands.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/ComputedNodeCommands.java deleted file mode 100644 index c611a3f..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/ComputedNodeCommands.java +++ /dev/null @@ -1,38 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal; - -import com.mojang.brigadier.CommandDispatcher; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.client.ui.WNodeScreen; -import net.minecraft.client.Minecraft; -import net.minecraft.commands.CommandSourceStack; -import net.minecraft.commands.Commands; -import net.minecraft.resources.ResourceLocation; - -/** https://github.com/webyep-art/webs_node_lib (MIT, webyep). */ -public final class ComputedNodeCommands { - private ComputedNodeCommands() {} - - public static void register(CommandDispatcher dispatcher) { - dispatcher.register(Commands.literal("webu") - .then(Commands.literal("node_editor") - .executes(context -> { - Minecraft.getInstance().tell(() -> { - WGraph demoGraph = new WGraph(); - WNode mathNode = NodeRegistry.createNode(ResourceLocation.fromNamespaceAndPath("computed", "math_add"), 100, 100); - WNode displayNode = NodeRegistry.createNode(ResourceLocation.fromNamespaceAndPath("computed", "display"), 300, 150); - if (mathNode != null) { - demoGraph.addNode(mathNode); - } - if (displayNode != null) { - demoGraph.addNode(displayNode); - } - Minecraft.getInstance().setScreen(new WNodeScreen(demoGraph)); - }); - return 1; - }) - ) - ); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/BoolToLevelNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/BoolToLevelNode.java deleted file mode 100644 index 1235db3..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/BoolToLevelNode.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.io; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class BoolToLevelNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("bool_to_level"); - public static final ResourceLocation MENU = BuiltinNodeCategories.IO; - public static final Component LABEL = Component.literal("Bool -> Level"); - - public BoolToLevelNode(int x, int y) { - super(TYPE_ID, "Bool -> Level", x, y); - addInput("In", 0xFF00FF88); - addOutput("Level", 0xFFFFBB00); - addElement(new WLabel("> 0.5 -> 15, else 0")); - setEvaluator(n -> n.getOutputs().get(0).setValue(n.getInputs().get(0).getValue() > 0.5 ? 15.0 : 0.0)); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, BoolToLevelNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/DisplayNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/DisplayNode.java deleted file mode 100644 index 859c720..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/DisplayNode.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.io; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class DisplayNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("display"); - public static final ResourceLocation MENU = BuiltinNodeCategories.IO; - public static final Component LABEL = Component.literal("Display"); - - public DisplayNode(int x, int y) { - super(TYPE_ID, "Display", x, y); - addInput("Value", 0xFF5555FF); - WLabel valLabel = new WLabel("0.00", 0xFF00FF88); - addElement(new WLabel("Current value:")); - addElement(valLabel); - setEvaluator(n -> { - double val = n.getInputs().get(0).getValue(); - valLabel.setText(String.format("%.2f", val)); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, DisplayNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/LevelToBoolNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/LevelToBoolNode.java deleted file mode 100644 index 2c93a16..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/io/LevelToBoolNode.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.io; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class LevelToBoolNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("level_to_bool"); - public static final ResourceLocation MENU = BuiltinNodeCategories.IO; - public static final Component LABEL = Component.literal("Level -> Bool"); - - public LevelToBoolNode(int x, int y) { - super(TYPE_ID, "Level -> Bool", x, y); - WSlider thresh = new WSlider("Threshold", 0, 15, 80); - thresh.setValue(8); - addInput("Level", 0xFFFFBB00); - addOutput("Bool", 0xFF00FF88); - addElement(new WLabel("1.0 if Level >= threshold")); - addElement(thresh); - setEvaluator(n -> n.getOutputs().get(0).setValue( - n.getInputs().get(0).getValue() >= thresh.getValue() ? 1.0 : 0.0)); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, LevelToBoolNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/AndNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/AndNode.java deleted file mode 100644 index 4a02504..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/AndNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class AndNode extends BinaryLogicNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("logic_and"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("AND"); - - public AndNode(int x, int y) { super(TYPE_ID, "AND", x, y, (a, b) -> a && b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, AndNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/BinaryLogicNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/BinaryLogicNode.java deleted file mode 100644 index 5b8d3aa..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/BinaryLogicNode.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import net.minecraft.resources.ResourceLocation; - -public abstract class BinaryLogicNode extends WNode { - - @FunctionalInterface - public interface LogicBinaryOp { - boolean apply(boolean a, boolean b); - } - - protected BinaryLogicNode(ResourceLocation typeId, String title, int x, int y, LogicBinaryOp op) { - super(typeId, title, x, y); - addInput("A", 0xFF00FF88); - addInput("B", 0xFF88CCFF); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel(title)); - setEvaluator(n -> { - boolean a = n.getInputs().get(0).getValue() > 0.5; - boolean b = n.getInputs().get(1).getValue() > 0.5; - n.getOutputs().get(0).setValue(op.apply(a, b) ? 1.0 : 0.0); - }); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/EdgeFallNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/EdgeFallNode.java deleted file mode 100644 index 67265fe..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/EdgeFallNode.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; - -public final class EdgeFallNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("edge_fall"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("Edge Fall"); - - private boolean prev; - - public EdgeFallNode(int x, int y) { - super(TYPE_ID, "Edge Fall", x, y); - addInput("In", 0xFF88CCFF); - addOutput("Pulse", 0xFF00FF88); - addElement(new WLabel("1.0 for one eval on falling edge")); - setEvaluator(n -> { - boolean now = n.getInputs().get(0).getValue() > 0.5; - boolean fall = !now && prev; - prev = now; - n.getOutputs().get(0).setValue(fall ? 1.0 : 0.0); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, EdgeFallNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putBoolean("Previous", prev); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - prev = tag.getBoolean("Previous"); - getOutputs().get(0).setValue(0.0); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/EdgeRiseNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/EdgeRiseNode.java deleted file mode 100644 index 3b4727d..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/EdgeRiseNode.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; - -public final class EdgeRiseNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("edge_rise"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("Edge Rise"); - - private boolean prev; - - public EdgeRiseNode(int x, int y) { - super(TYPE_ID, "Edge Rise", x, y); - addInput("In", 0xFF88CCFF); - addOutput("Pulse", 0xFF00FF88); - addElement(new WLabel("1.0 for one eval on rising edge")); - setEvaluator(n -> { - boolean now = n.getInputs().get(0).getValue() > 0.5; - boolean rise = now && !prev; - prev = now; - n.getOutputs().get(0).setValue(rise ? 1.0 : 0.0); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, EdgeRiseNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putBoolean("Previous", prev); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - prev = tag.getBoolean("Previous"); - getOutputs().get(0).setValue(0.0); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/NandNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/NandNode.java deleted file mode 100644 index efac49c..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/NandNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class NandNode extends BinaryLogicNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("logic_nand"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("NAND"); - - public NandNode(int x, int y) { super(TYPE_ID, "NAND", x, y, (a, b) -> !(a && b)); } - - public static void register() { - NodeRegistry.register(TYPE_ID, NandNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/NorNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/NorNode.java deleted file mode 100644 index b839088..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/NorNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class NorNode extends BinaryLogicNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("logic_nor"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("NOR"); - - public NorNode(int x, int y) { super(TYPE_ID, "NOR", x, y, (a, b) -> !(a || b)); } - - public static void register() { - NodeRegistry.register(TYPE_ID, NorNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/OrNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/OrNode.java deleted file mode 100644 index 08fedb2..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/OrNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class OrNode extends BinaryLogicNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("logic_or"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("OR"); - - public OrNode(int x, int y) { super(TYPE_ID, "OR", x, y, (a, b) -> a || b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, OrNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/SchmittNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/SchmittNode.java deleted file mode 100644 index b56cca0..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/SchmittNode.java +++ /dev/null @@ -1,65 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class SchmittNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("schmitt"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("Schmitt"); - - private boolean on; - private final WSlider onThresh; - private final WSlider offThresh; - - public SchmittNode(int x, int y) { - super(TYPE_ID, "Schmitt", x, y); - onThresh = new WSlider("On threshold", 0.0, 15.0, 80); - onThresh.setValue(10.0); - offThresh = new WSlider("Off threshold", 0.0, 15.0, 80); - offThresh.setValue(5.0); - addInput("In", 0xFF88CCFF); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel("Hysteresis (on >= on, off <= off)")); - addElement(onThresh); - addElement(offThresh); - setEvaluator(n -> { - double v = n.getInputs().get(0).getValue(); - double hi = onThresh.getValue(); - double lo = offThresh.getValue(); - if (on) { if (v <= lo) on = false; } - else { if (v >= hi) on = true; } - n.getOutputs().get(0).setValue(on ? 1.0 : 0.0); - }); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putBoolean("On", on); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - if (tag.contains("On")) on = tag.getBoolean("On"); - getOutputs().get(0).setValue(on ? 1.0 : 0.0); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, SchmittNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/XnorNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/XnorNode.java deleted file mode 100644 index 592ec48..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/XnorNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class XnorNode extends BinaryLogicNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("logic_xnor"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("XNOR"); - - public XnorNode(int x, int y) { super(TYPE_ID, "XNOR", x, y, (a, b) -> !(a ^ b)); } - - public static void register() { - NodeRegistry.register(TYPE_ID, XnorNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/XorNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/XorNode.java deleted file mode 100644 index 5e8f561..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/binary/XorNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class XorNode extends BinaryLogicNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("logic_xor"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_BINARY; - public static final Component LABEL = Component.literal("XOR"); - - public XorNode(int x, int y) { super(TYPE_ID, "XOR", x, y, (a, b) -> a ^ b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, XorNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/ApproxNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/ApproxNode.java deleted file mode 100644 index ba8fcda..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/ApproxNode.java +++ /dev/null @@ -1,38 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class ApproxNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("cmp_approx"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_COMPARISON; - public static final Component LABEL = Component.literal("~="); - - public ApproxNode(int x, int y) { - super(TYPE_ID, "~=", x, y); - addInput("A", 0xFF00FF88); - addInput("B", 0xFF88CCFF); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel("A ~= B")); - WSlider tolerance = new WSlider("Tolerance", 0.0, 15.0, 80); - tolerance.setValue(0.5); - addElement(tolerance); - setEvaluator(n -> { - double a = n.getInputs().get(0).getValue(); - double b = n.getInputs().get(1).getValue(); - n.getOutputs().get(0).setValue(Math.abs(a - b) <= tolerance.getValue() ? 1.0 : 0.0); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, ApproxNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/ComparisonNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/ComparisonNode.java deleted file mode 100644 index 6eed9ab..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/ComparisonNode.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison; - -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import net.minecraft.resources.ResourceLocation; - -public abstract class ComparisonNode extends WNode { - - @FunctionalInterface - public interface CompareOp { - boolean apply(double a, double b); - } - - protected ComparisonNode(ResourceLocation typeId, String title, String label, int x, int y, CompareOp op) { - super(typeId, title, x, y); - addInput("A", 0xFF00FF88); - addInput("B", 0xFF88CCFF); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel(label)); - setEvaluator(n -> { - double a = n.getInputs().get(0).getValue(); - double b = n.getInputs().get(1).getValue(); - n.getOutputs().get(0).setValue(op.apply(a, b) ? 1.0 : 0.0); - }); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/EqualNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/EqualNode.java deleted file mode 100644 index e062aac..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/EqualNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class EqualNode extends ComparisonNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("cmp_eq"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_COMPARISON; - public static final Component LABEL = Component.literal("="); - - public EqualNode(int x, int y) { super(TYPE_ID, "=", "A = B", x, y, (a, b) -> a == b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, EqualNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/GreaterEqualNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/GreaterEqualNode.java deleted file mode 100644 index 2323e6b..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/GreaterEqualNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class GreaterEqualNode extends ComparisonNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("cmp_ge"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_COMPARISON; - public static final Component LABEL = Component.literal(">="); - - public GreaterEqualNode(int x, int y) { super(TYPE_ID, ">=", "A >= B", x, y, (a, b) -> a >= b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, GreaterEqualNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/GreaterThanNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/GreaterThanNode.java deleted file mode 100644 index a74e1df..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/GreaterThanNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class GreaterThanNode extends ComparisonNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("cmp_gt"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_COMPARISON; - public static final Component LABEL = Component.literal(">"); - - public GreaterThanNode(int x, int y) { super(TYPE_ID, ">", "A > B", x, y, (a, b) -> a > b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, GreaterThanNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/LessEqualNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/LessEqualNode.java deleted file mode 100644 index 7a6728b..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/LessEqualNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class LessEqualNode extends ComparisonNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("cmp_le"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_COMPARISON; - public static final Component LABEL = Component.literal("<="); - - public LessEqualNode(int x, int y) { super(TYPE_ID, "<=", "A <= B", x, y, (a, b) -> a <= b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, LessEqualNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/LessThanNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/LessThanNode.java deleted file mode 100644 index b4c177c..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/comparison/LessThanNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.comparison; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class LessThanNode extends ComparisonNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("cmp_lt"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_COMPARISON; - public static final Component LABEL = Component.literal("<"); - - public LessThanNode(int x, int y) { super(TYPE_ID, "<", "A < B", x, y, (a, b) -> a < b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, LessThanNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/memory/DFlipFlopNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/memory/DFlipFlopNode.java deleted file mode 100644 index 8fe1ee7..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/memory/DFlipFlopNode.java +++ /dev/null @@ -1,59 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.memory; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class DFlipFlopNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("d_flipflop"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_MEMORY; - public static final Component LABEL = Component.literal("D Flip-flop"); - - private boolean q; - private boolean prevClock; - - public DFlipFlopNode(int x, int y) { - super(TYPE_ID, "D Flip-flop", x, y); - addInput("Data", 0xFF88CCFF); - addInput("Clock", 0xFF00FF88); - addOutput("Q", 0xFFFF5555); - addElement(new WLabel("Latches Data on Clock rise")); - setEvaluator(n -> { - boolean d = n.getInputs().get(0).getValue() > 0.5; - boolean c = n.getInputs().get(1).getValue() > 0.5; - if (c && !prevClock) q = d; - prevClock = c; - n.getOutputs().get(0).setValue(q ? 1.0 : 0.0); - }); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putBoolean("Q", q); - tag.putBoolean("PrevClock", prevClock); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - if (tag.contains("Q")) q = tag.getBoolean("Q"); - prevClock = tag.getBoolean("PrevClock"); - getOutputs().get(0).setValue(q ? 1.0 : 0.0); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, DFlipFlopNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/memory/SrLatchNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/memory/SrLatchNode.java deleted file mode 100644 index 5c48a1e..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/memory/SrLatchNode.java +++ /dev/null @@ -1,64 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.memory; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class SrLatchNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("sr_latch"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_MEMORY; - public static final Component LABEL = Component.literal("SR Latch"); - - private boolean q; - private boolean prevSet; - private boolean prevReset; - - public SrLatchNode(int x, int y) { - super(TYPE_ID, "SR Latch", x, y); - addInput("Set", 0xFF00FF88); - addInput("Reset", 0xFFFF6666); - addOutput("Q", 0xFFFF5555); - addElement(new WLabel("SR Latch (rising edges)")); - setEvaluator(n -> { - boolean s = n.getInputs().get(0).getValue() > 0.5; - boolean r = n.getInputs().get(1).getValue() > 0.5; - if (r && !prevReset) q = false; - else if (s && !prevSet) q = true; - prevSet = s; - prevReset = r; - n.getOutputs().get(0).setValue(q ? 1.0 : 0.0); - }); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putBoolean("Q", q); - tag.putBoolean("PrevSet", prevSet); - tag.putBoolean("PrevReset", prevReset); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - if (tag.contains("Q")) q = tag.getBoolean("Q"); - prevSet = tag.getBoolean("PrevSet"); - prevReset = tag.getBoolean("PrevReset"); - getOutputs().get(0).setValue(q ? 1.0 : 0.0); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, SrLatchNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/unary/NotNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/unary/NotNode.java deleted file mode 100644 index bcb3a85..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/logic/unary/NotNode.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.logic.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class NotNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("logic_not"); - public static final ResourceLocation MENU = BuiltinNodeCategories.LOGIC_UNARY; - public static final Component LABEL = Component.literal("NOT"); - - public NotNode(int x, int y) { - super(TYPE_ID, "NOT", x, y); - addInput("A", 0xFF00FF88); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel("NOT")); - setEvaluator(n -> n.getOutputs().get(0).setValue(n.getInputs().get(0).getValue() > 0.5 ? 0.0 : 1.0)); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, NotNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/AddNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/AddNode.java deleted file mode 100644 index ff4081e..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/AddNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class AddNode extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_add"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Add"); - - public AddNode(int x, int y) { super(TYPE_ID, "Add", x, y, (a, b) -> a + b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, AddNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/BinaryMathNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/BinaryMathNode.java deleted file mode 100644 index 40b92ee..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/BinaryMathNode.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import net.minecraft.resources.ResourceLocation; - -public abstract class BinaryMathNode extends WNode { - - @FunctionalInterface - public interface BinaryOp { - double apply(double a, double b); - } - - protected BinaryMathNode(ResourceLocation typeId, String title, int x, int y, BinaryOp op) { - super(typeId, title, x, y); - addInput("A", 0xFF00FF88); - addInput("B", 0xFF00FF88); - addOutput("Result", 0xFFFF5555); - addElement(new WLabel(title)); - setEvaluator(n -> { - double a = n.getInputs().get(0).getValue(); - double b = n.getInputs().get(1).getValue(); - n.getOutputs().get(0).setValue(op.apply(a, b)); - }); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/ClampNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/ClampNode.java deleted file mode 100644 index 4081260..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/ClampNode.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -public final class ClampNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_clamp"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Clamp"); - - public ClampNode(int x, int y) { - super(TYPE_ID, "Clamp", x, y); - addInput("x", 0xFF88CCFF); - addInput("Min", 0xFF00FF88); - addInput("Max", 0xFFFF6666); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel("clamp(x, min, max)")); - setEvaluator(n -> { - double v = n.getInputs().get(0).getValue(); - double a = n.getInputs().get(1).getValue(); - double b = n.getInputs().get(2).getValue(); - double lo = Math.min(a, b); - double hi = Math.max(a, b); - n.getOutputs().get(0).setValue(Mth.clamp(v, lo, hi)); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, ClampNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/DivideNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/DivideNode.java deleted file mode 100644 index 042e96f..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/DivideNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class DivideNode extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_divide"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Divide"); - - public DivideNode(int x, int y) { super(TYPE_ID, "Divide", x, y, (a, b) -> b != 0 ? a / b : 0); } - - public static void register() { - NodeRegistry.register(TYPE_ID, DivideNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/LerpNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/LerpNode.java deleted file mode 100644 index b13fee5..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/LerpNode.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class LerpNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_lerp"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Lerp"); - - public LerpNode(int x, int y) { - super(TYPE_ID, "Lerp", x, y); - addInput("A", 0xFF88CCFF); - addInput("B", 0xFF88CCFF); - addInput("T", 0xFF00FF88); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel("A + (B - A) * T")); - setEvaluator(n -> { - double a = n.getInputs().get(0).getValue(); - double b = n.getInputs().get(1).getValue(); - double t = n.getInputs().get(2).getValue(); - n.getOutputs().get(0).setValue(a + (b - a) * t); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, LerpNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MapNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MapNode.java deleted file mode 100644 index f9bd465..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MapNode.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class MapNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_map"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Map"); - - public MapNode(int x, int y) { - super(TYPE_ID, "Map", x, y); - addInput("x", 0xFF88CCFF); - addInput("In min", 0xFF00FF88); - addInput("In max", 0xFF00FF88); - addInput("Out min", 0xFFFFBB00); - addInput("Out max", 0xFFFFBB00); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel("Linear remap")); - setEvaluator(n -> { - double x_ = n.getInputs().get(0).getValue(); - double i0 = n.getInputs().get(1).getValue(); - double i1 = n.getInputs().get(2).getValue(); - double o0 = n.getInputs().get(3).getValue(); - double o1 = n.getInputs().get(4).getValue(); - if (i1 == i0) n.getOutputs().get(0).setValue(o0); - else n.getOutputs().get(0).setValue(o0 + (x_ - i0) * (o1 - o0) / (i1 - i0)); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, MapNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MaxNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MaxNode.java deleted file mode 100644 index b580dbb..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MaxNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class MaxNode extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_max"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Max"); - - public MaxNode(int x, int y) { super(TYPE_ID, "Max", x, y, Math::max); } - - public static void register() { - NodeRegistry.register(TYPE_ID, MaxNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MinNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MinNode.java deleted file mode 100644 index 461d27b..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MinNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class MinNode extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_min"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Min"); - - public MinNode(int x, int y) { super(TYPE_ID, "Min", x, y, Math::min); } - - public static void register() { - NodeRegistry.register(TYPE_ID, MinNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/ModuloNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/ModuloNode.java deleted file mode 100644 index a152096..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/ModuloNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class ModuloNode extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_mod"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Modulo"); - - public ModuloNode(int x, int y) { super(TYPE_ID, "Modulo", x, y, (a, b) -> b != 0 ? a % b : 0); } - - public static void register() { - NodeRegistry.register(TYPE_ID, ModuloNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MultiplyNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MultiplyNode.java deleted file mode 100644 index 851a509..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/MultiplyNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class MultiplyNode extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_multiply"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Multiply"); - - public MultiplyNode(int x, int y) { super(TYPE_ID, "Multiply", x, y, (a, b) -> a * b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, MultiplyNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/PowerNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/PowerNode.java deleted file mode 100644 index c9e6c73..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/PowerNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class PowerNode extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_pow"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Power"); - - public PowerNode(int x, int y) { super(TYPE_ID, "Power", x, y, Math::pow); } - - public static void register() { - NodeRegistry.register(TYPE_ID, PowerNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/SubtractNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/SubtractNode.java deleted file mode 100644 index 7d7c8d0..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/binary/SubtractNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.binary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class SubtractNode extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_subtract"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_BINARY; - public static final Component LABEL = Component.literal("Subtract"); - - public SubtractNode(int x, int y) { super(TYPE_ID, "Subtract", x, y, (a, b) -> a - b); } - - public static void register() { - NodeRegistry.register(TYPE_ID, SubtractNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/Atan2Node.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/Atan2Node.java deleted file mode 100644 index 7ba8105..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/Atan2Node.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.trig; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.binary.BinaryMathNode; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class Atan2Node extends BinaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_atan2"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_TRIG; - public static final Component LABEL = Component.literal("Atan2"); - - public Atan2Node(int x, int y) { super(TYPE_ID, "Atan2", x, y, Math::atan2); } - - public static void register() { - NodeRegistry.register(TYPE_ID, Atan2Node::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/CosNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/CosNode.java deleted file mode 100644 index eee8658..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/CosNode.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.trig; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.UnaryMathNode; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class CosNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_cos"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_TRIG; - public static final Component LABEL = Component.literal("Cos"); - - public CosNode(int x, int y) { super(TYPE_ID, "Cos", x, y, Math::cos); } - - public static void register() { - NodeRegistry.register(TYPE_ID, CosNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/SinNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/SinNode.java deleted file mode 100644 index 0330748..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/SinNode.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.trig; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.UnaryMathNode; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class SinNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_sin"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_TRIG; - public static final Component LABEL = Component.literal("Sin"); - - public SinNode(int x, int y) { super(TYPE_ID, "Sin", x, y, Math::sin); } - - public static void register() { - NodeRegistry.register(TYPE_ID, SinNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/TanNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/TanNode.java deleted file mode 100644 index f920c11..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/trig/TanNode.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.trig; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import dev.propulsionteam.computed.internal.node.internal.nodes.math.unary.UnaryMathNode; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class TanNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_tan"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_TRIG; - public static final Component LABEL = Component.literal("Tan"); - - public TanNode(int x, int y) { super(TYPE_ID, "Tan", x, y, Math::tan); } - - public static void register() { - NodeRegistry.register(TYPE_ID, TanNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/AbsNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/AbsNode.java deleted file mode 100644 index 06baae3..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/AbsNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class AbsNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_abs"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Abs"); - - public AbsNode(int x, int y) { super(TYPE_ID, "Abs", x, y, Math::abs); } - - public static void register() { - NodeRegistry.register(TYPE_ID, AbsNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/AverageNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/AverageNode.java deleted file mode 100644 index 9d9f936..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/AverageNode.java +++ /dev/null @@ -1,61 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class AverageNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_average"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Average"); - - private final WSlider window; - private double[] buf = new double[1]; - private int head = 0; - private int size = 0; - private int cap = 1; - private double sum = 0.0; - - public AverageNode(int x, int y) { - super(TYPE_ID, "Average", x, y); - window = new WSlider("Window (ticks)", 1, 100, 80); - window.setValue(20); - addInput("In", 0xFF88CCFF); - addOutput("Mean", 0xFFFF5555); - addElement(new WLabel("Windowed mean (per tick)")); - addElement(window); - setEvaluator(n -> { - int w = Math.max(1, (int) window.getValue()); - if (w != cap) { - buf = new double[w]; - head = 0; - size = 0; - sum = 0.0; - cap = w; - } - WGraph g = n.evaluationGraph(); - if (g != null && g.isEvalTickPulseGate()) { - double in = n.getInputs().get(0).getValue(); - if (size == cap) sum -= buf[head]; - else size++; - buf[head] = in; - sum += in; - head = (head + 1) % cap; - } - double mean = size == 0 ? 0.0 : sum / size; - n.getOutputs().get(0).setValue(mean); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, AverageNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/CeilNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/CeilNode.java deleted file mode 100644 index 3b7e0e6..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/CeilNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class CeilNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_ceil"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Ceil"); - - public CeilNode(int x, int y) { super(TYPE_ID, "Ceil", x, y, Math::ceil); } - - public static void register() { - NodeRegistry.register(TYPE_ID, CeilNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/ExpNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/ExpNode.java deleted file mode 100644 index 9b4dfb5..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/ExpNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class ExpNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_exp"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Exp"); - - public ExpNode(int x, int y) { super(TYPE_ID, "Exp", x, y, Math::exp); } - - public static void register() { - NodeRegistry.register(TYPE_ID, ExpNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/FloorNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/FloorNode.java deleted file mode 100644 index 324716f..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/FloorNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class FloorNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_floor"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Floor"); - - public FloorNode(int x, int y) { super(TYPE_ID, "Floor", x, y, Math::floor); } - - public static void register() { - NodeRegistry.register(TYPE_ID, FloorNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/Log10Node.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/Log10Node.java deleted file mode 100644 index 4063b1d..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/Log10Node.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class Log10Node extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_log10"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Log10"); - - public Log10Node(int x, int y) { super(TYPE_ID, "Log10", x, y, a -> a > 0 ? Math.log10(a) : 0); } - - public static void register() { - NodeRegistry.register(TYPE_ID, Log10Node::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/LogNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/LogNode.java deleted file mode 100644 index 2415be5..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/LogNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class LogNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_log"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Log (ln)"); - - public LogNode(int x, int y) { super(TYPE_ID, "Log (ln)", x, y, a -> a > 0 ? Math.log(a) : 0); } - - public static void register() { - NodeRegistry.register(TYPE_ID, LogNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/NegateNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/NegateNode.java deleted file mode 100644 index 9a4c64d..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/NegateNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class NegateNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_negate"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Negate"); - - public NegateNode(int x, int y) { super(TYPE_ID, "Negate", x, y, a -> -a); } - - public static void register() { - NodeRegistry.register(TYPE_ID, NegateNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/QuantizeRedstoneNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/QuantizeRedstoneNode.java deleted file mode 100644 index 3a58bfd..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/QuantizeRedstoneNode.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -public final class QuantizeRedstoneNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("quantize_redstone"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Quantize 0-15"); - - public QuantizeRedstoneNode(int x, int y) { - super(TYPE_ID, "Quantize 0-15", x, y); - addInput("x", 0xFF88CCFF); - addOutput("Level", 0xFFFFBB00); - addElement(new WLabel("round + clamp to 0-15")); - setEvaluator(n -> { - double v = n.getInputs().get(0).getValue(); - int q = Mth.clamp((int) Math.round(v), 0, 15); - n.getOutputs().get(0).setValue(q); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, QuantizeRedstoneNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/RandomNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/RandomNode.java deleted file mode 100644 index 2ac8319..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/RandomNode.java +++ /dev/null @@ -1,57 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class RandomNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_random"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Random"); - - public RandomNode(int x, int y) { - super(TYPE_ID, "Random", x, y); - addOutput("Result", 0xFFFFAA00); - WTextField minF = new WTextField(88); - WTextField maxF = new WTextField(88); - minF.setValue("0"); - maxF.setValue("1"); - addElement(new WLabel("Uniform in [min, max]")); - addElement(new WLabel("Min")); - addElement(minF); - addElement(new WLabel("Max")); - addElement(maxF); - setEvaluator(n -> { - double a = parseLooseDouble(minF, 0.0); - double b = parseLooseDouble(maxF, 1.0); - double lo = Math.min(a, b); - double hi = Math.max(a, b); - double span = hi - lo; - double r = lo + (span > 0 - ? java.util.concurrent.ThreadLocalRandom.current().nextDouble() * span - : 0.0); - n.getOutputs().get(0).setValue(r); - }); - } - - private static double parseLooseDouble(WTextField field, double fallback) { - try { - String s = field.getValue().trim().replace(',', '.'); - if (s.isEmpty()) return fallback; - return Double.parseDouble(s); - } catch (NumberFormatException e) { - return fallback; - } - } - - public static void register() { - NodeRegistry.register(TYPE_ID, RandomNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/RoundNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/RoundNode.java deleted file mode 100644 index dba304f..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/RoundNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class RoundNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_round"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Round"); - - public RoundNode(int x, int y) { super(TYPE_ID, "Round", x, y, Math::rint); } - - public static void register() { - NodeRegistry.register(TYPE_ID, RoundNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/SignNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/SignNode.java deleted file mode 100644 index 9242981..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/SignNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class SignNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_sign"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Sign"); - - public SignNode(int x, int y) { super(TYPE_ID, "Sign", x, y, Math::signum); } - - public static void register() { - NodeRegistry.register(TYPE_ID, SignNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/SqrtNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/SqrtNode.java deleted file mode 100644 index 6f64b12..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/SqrtNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class SqrtNode extends UnaryMathNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("math_sqrt"); - public static final ResourceLocation MENU = BuiltinNodeCategories.MATH_UNARY; - public static final Component LABEL = Component.literal("Sqrt"); - - public SqrtNode(int x, int y) { super(TYPE_ID, "Sqrt", x, y, a -> Math.sqrt(Math.max(0, a))); } - - public static void register() { - NodeRegistry.register(TYPE_ID, SqrtNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/UnaryMathNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/UnaryMathNode.java deleted file mode 100644 index 4af60aa..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/math/unary/UnaryMathNode.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.math.unary; - -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import net.minecraft.resources.ResourceLocation; - -public abstract class UnaryMathNode extends WNode { - - @FunctionalInterface - public interface UnaryOp { - double apply(double a); - } - - protected UnaryMathNode(ResourceLocation typeId, String title, int x, int y, UnaryOp op) { - super(typeId, title, x, y); - addInput("A", 0xFF00FF88); - addOutput("Result", 0xFFFF5555); - addElement(new WLabel(title)); - setEvaluator(n -> n.getOutputs().get(0).setValue(op.apply(n.getInputs().get(0).getValue()))); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/ConstantNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/ConstantNode.java deleted file mode 100644 index 36377b3..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/ConstantNode.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.sources; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WTextField; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class ConstantNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("constant"); - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Constant"); - - public ConstantNode(int x, int y) { - super(TYPE_ID, "Constant", x, y); - addOutput("Value", 0xFFFFBB00); - WTextField valField = new WTextField(60); - valField.setValue("10.0"); - addElement(new WLabel("Value:")); - addElement(valField); - setEvaluator(n -> { - try { - n.getOutputs().get(0).setValue(Double.parseDouble(valField.getValue())); - } catch (Exception ignored) {} - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, ConstantNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/DelayNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/DelayNode.java deleted file mode 100644 index 28d1f3a..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/DelayNode.java +++ /dev/null @@ -1,83 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.sources; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.DoubleTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.Tag; - -public final class DelayNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("delay"); - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Delay"); - - private final WSlider delay; - private double[] buf = new double[1]; - private int head = 0; - private int cap = 1; - - public DelayNode(int x, int y) { - super(TYPE_ID, "Delay", x, y); - delay = new WSlider("Delay (ticks)", 0, 200, 80); - delay.setValue(1); - addInput("In", 0xFF88CCFF); - addOutput("Out", 0xFFFF5555); - addElement(new WLabel("Delays input N ticks")); - addElement(delay); - setEvaluator(n -> { - int d = Math.max(0, (int) delay.getValue()); - int needed = Math.max(1, d + 1); - if (needed != cap) { - buf = new double[needed]; - head = 0; - cap = needed; - } - WGraph g = n.evaluationGraph(); - if (g != null && g.isEvalTickPulseGate()) { - buf[head] = n.getInputs().get(0).getValue(); - head = (head + 1) % cap; - } - n.getOutputs().get(0).setValue(buf[head]); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, DelayNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putInt("BufferHead", head); - ListTag values = new ListTag(); - for (double value : buf) values.add(DoubleTag.valueOf(value)); - tag.put("Buffer", values); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - ListTag values = tag.getList("Buffer", Tag.TAG_DOUBLE); - if (!values.isEmpty() && values.size() <= 201) { - buf = new double[values.size()]; - for (int i = 0; i < values.size(); i++) buf[i] = values.getDouble(i); - cap = buf.length; - head = Math.floorMod(tag.getInt("BufferHead"), cap); - } - getOutputs().get(0).setValue(buf[head]); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/OscillatorNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/OscillatorNode.java deleted file mode 100644 index 85820be..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/OscillatorNode.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.sources; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class OscillatorNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("oscillator"); - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Oscillator"); - - public OscillatorNode(int x, int y) { - super(TYPE_ID, "Oscillator", x, y); - addOutput("Wave", 0xFF00FFFF); - WSlider freqSlider = new WSlider("Freq", 0.1, 5.0, 80); - WSlider ampSlider = new WSlider("Amp", 1.0, 100.0, 80); - addElement(new WLabel("Sine Wave Generator")); - addElement(freqSlider); - addElement(ampSlider); - setEvaluator(n -> { - double time = System.currentTimeMillis() / 1000.0; - double val = Math.sin(time * freqSlider.getValue() * Math.PI * 2) * ampSlider.getValue(); - n.getOutputs().get(0).setValue(val); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, OscillatorNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/PulseNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/PulseNode.java deleted file mode 100644 index 1997300..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/PulseNode.java +++ /dev/null @@ -1,47 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.sources; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class PulseNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("pulse"); - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Pulse"); - - private int phase = 0; - - public PulseNode(int x, int y) { - super(TYPE_ID, "Pulse", x, y); - addOutput("Tick", 0xFF00FF88); - WSlider cooldown = new WSlider("Cooldown (ticks)", 1, 20, 100); - cooldown.setValue(20); - addElement(new WLabel("Pulses 1.0 every N ticks")); - addElement(cooldown); - setEvaluator(n -> { - int cd = (int) cooldown.getValue(); - if (cd <= 0) { - n.getOutputs().get(0).setValue(1.0); - phase = 0; - return; - } - WGraph g = n.evaluationGraph(); - if (g != null && g.isEvalTickPulseGate()) { - phase = (phase + 1) % (2 * cd); - } - n.getOutputs().get(0).setValue(phase < cd ? 1.0 : 0.0); - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, PulseNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/SampleHoldNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/SampleHoldNode.java deleted file mode 100644 index f460d76..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/SampleHoldNode.java +++ /dev/null @@ -1,58 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.sources; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class SampleHoldNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("sample_hold"); - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Sample & Hold"); - - private double held = 0.0; - private boolean prevClock; - - public SampleHoldNode(int x, int y) { - super(TYPE_ID, "Sample & Hold", x, y); - addInput("In", 0xFF88CCFF); - addInput("Clock", 0xFF00FF88); - addOutput("Held", 0xFFFF5555); - addElement(new WLabel("Captures In on Clock rise")); - setEvaluator(n -> { - boolean c = n.getInputs().get(1).getValue() > 0.5; - if (c && !prevClock) held = n.getInputs().get(0).getValue(); - prevClock = c; - n.getOutputs().get(0).setValue(held); - }); - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putDouble("Held", held); - tag.putBoolean("PrevClock", prevClock); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - if (tag.contains("Held")) held = tag.getDouble("Held"); - prevClock = tag.getBoolean("PrevClock"); - getOutputs().get(0).setValue(held); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, SampleHoldNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } - - @Override - public boolean isStateBoundary() { return true; } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/TickNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/TickNode.java deleted file mode 100644 index 1e3c580..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/sources/TickNode.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.sources; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; - -public final class TickNode extends WNode { - public static final ResourceLocation TYPE_ID = WGraph.TICK_NODE_TYPE; - public static final ResourceLocation MENU = BuiltinNodeCategories.SOURCES; - public static final Component LABEL = Component.literal("Tick"); - - public TickNode(int x, int y) { - super(TYPE_ID, "Tick", x, y); - addOutput("Tick", 0xFF00FF88); - addOutput("Delta time", 0xFF88CCFF); - WSlider rate = new WSlider("Rate", 0, WGraph.MAX_TICK_RATE, 100); - rate.setValue(WGraph.MAX_TICK_RATE); - addElement(new WLabel("Graph clock")); - addElement(rate); - addElement(new WLabel("Rate: updates per second (0 = pause)")); - setEvaluator(n -> {}); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, TickNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/visuals/RgbPreviewNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/visuals/RgbPreviewNode.java deleted file mode 100644 index 0f78f6c..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/visuals/RgbPreviewNode.java +++ /dev/null @@ -1,48 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.visuals; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WElement; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WLabel; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.Mth; - -public final class RgbPreviewNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("rgb_preview"); - public static final ResourceLocation MENU = BuiltinNodeCategories.VISUALS; - public static final Component LABEL = Component.literal("RGB Preview"); - - public RgbPreviewNode(int x, int y) { - super(TYPE_ID, "RGB Preview", x, y); - addInput("R", 0xFFFF0000); - addInput("G", 0xFF00FF00); - addInput("B", 0xFF0000FF); - addElement(new WLabel("Color Result:")); - WNode self = this; - addElement(new WElement() { - { - this.width = 60; - this.height = 30; - } - - @Override - public void render(GuiGraphics g, int x, int y, int mx, int my, float pt) { - int r = (int) Mth.clamp(self.getInputs().get(0).getValue(), 0, 255); - int g1 = (int) Mth.clamp(self.getInputs().get(1).getValue(), 0, 255); - int b = (int) Mth.clamp(self.getInputs().get(2).getValue(), 0, 255); - g.fill(x, y, x + width, y + height, 0xFF000000 | (r << 16) | (g1 << 8) | b); - g.renderOutline(x, y, width, height, 0xFFFFFFFF); - } - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, RgbPreviewNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/visuals/Viewport3DNode.java b/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/visuals/Viewport3DNode.java deleted file mode 100644 index 97509f8..0000000 --- a/src/main/java/dev/propulsionteam/computed/internal/node/internal/nodes/visuals/Viewport3DNode.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.propulsionteam.computed.internal.node.internal.nodes.visuals; - -import dev.propulsionteam.computed.internal.node.api.NodeMenuRegistry; -import dev.propulsionteam.computed.internal.node.api.NodeRegistry; -import dev.propulsionteam.computed.internal.node.api.WNode; -import dev.propulsionteam.computed.internal.node.api.elements.WSlider; -import dev.propulsionteam.computed.internal.node.api.elements.WViewport3D; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeCategories; -import dev.propulsionteam.computed.internal.node.internal.BuiltinNodeIds; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.block.Blocks; -import org.joml.Vector3f; - -public final class Viewport3DNode extends WNode { - public static final ResourceLocation TYPE_ID = BuiltinNodeIds.of("3d_preview"); - public static final ResourceLocation MENU = BuiltinNodeCategories.VISUALS; - public static final Component LABEL = Component.literal("3D Viewport"); - - public Viewport3DNode(int x, int y) { - super(TYPE_ID, "3D Viewport", x, y); - setWidth(150); - WViewport3D viewport = new WViewport3D(140, 100); - WSlider rotX = new WSlider("Rot X", 0, 360, 130); - WSlider rotY = new WSlider("Rot Y", 0, 360, 130); - ItemStack stack = new ItemStack(Blocks.DIAMOND_BLOCK); - viewport.addModel(stack, new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), 1.0f); - addElement(viewport); - addElement(rotX); - addElement(rotY); - setEvaluator(n -> { - if (!viewport.getModels().isEmpty()) { - viewport.getModels().get(0).rot.x = (float) rotX.getValue(); - viewport.getModels().get(0).rot.y = (float) rotY.getValue(); - } - }); - } - - public static void register() { - NodeRegistry.register(TYPE_ID, Viewport3DNode::new); - NodeMenuRegistry.addNodeEntry(MENU, TYPE_ID, LABEL); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaCompilationException.java b/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaCompilationException.java new file mode 100644 index 0000000..53379b2 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaCompilationException.java @@ -0,0 +1,7 @@ +package dev.propulsionteam.computed.lua.compiler; + +public final class LuaCompilationException extends RuntimeException { + public LuaCompilationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaCompiledSource.java b/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaCompiledSource.java new file mode 100644 index 0000000..f99d99c --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaCompiledSource.java @@ -0,0 +1,14 @@ +package dev.propulsionteam.computed.lua.compiler; + +import java.util.Objects; +import org.luaj.vm2.Prototype; + +public record LuaCompiledSource(int apiVersion, String sourceHash, Prototype prototype) { + public LuaCompiledSource { + if (apiVersion < 1) { + throw new IllegalArgumentException("apiVersion must be positive"); + } + Objects.requireNonNull(sourceHash, "sourceHash"); + Objects.requireNonNull(prototype, "prototype"); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaSourceCompiler.java b/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaSourceCompiler.java new file mode 100644 index 0000000..f8dde59 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/compiler/LuaSourceCompiler.java @@ -0,0 +1,81 @@ +package dev.propulsionteam.computed.lua.compiler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.luaj.vm2.Globals; +import org.luaj.vm2.LuaError; +import org.luaj.vm2.Prototype; +import org.luaj.vm2.compiler.LuaC; + +public final class LuaSourceCompiler { + public static final int MAX_SOURCE_BYTES = 64 * 1024; + + private static final ConcurrentMap PROTOTYPES = new ConcurrentHashMap<>(); + private final Globals compilerGlobals; + + public LuaSourceCompiler() { + compilerGlobals = new Globals(); + LuaC.install(compilerGlobals); + } + + public LuaCompiledSource compile(int apiVersion, String source) { + if (apiVersion < 1) { + throw new IllegalArgumentException("apiVersion must be positive"); + } + Objects.requireNonNull(source, "source"); + byte[] encoded = source.getBytes(StandardCharsets.UTF_8); + if (encoded.length > MAX_SOURCE_BYTES) { + throw new LuaCompilationException( + "Lua source exceeds the " + MAX_SOURCE_BYTES + "-byte limit", + new IllegalArgumentException("source too large")); + } + String hash = sha256(encoded); + CacheKey key = new CacheKey(apiVersion, hash); + try { + Prototype prototype = PROTOTYPES.computeIfAbsent(key, ignored -> compilePrototype(source, hash)); + return new LuaCompiledSource(apiVersion, hash, prototype); + } catch (CompilationFailure failure) { + throw new LuaCompilationException(failure.getCause().getMessage(), failure.getCause()); + } + } + + public static int cachedPrototypeCount() { + return PROTOTYPES.size(); + } + + static void clearCache() { + PROTOTYPES.clear(); + } + + private Prototype compilePrototype(String source, String hash) { + try { + return compilerGlobals.compilePrototype( + new java.io.StringReader(source), + "@computed/" + hash.substring(0, 12) + ".lua"); + } catch (IOException | LuaError exception) { + throw new CompilationFailure(exception); + } + } + + private static String sha256(byte[] source) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(source)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private record CacheKey(int apiVersion, String hash) {} + + private static final class CompilationFailure extends RuntimeException { + private CompilationFailure(Throwable cause) { + super(cause); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpointHost.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpointHost.java new file mode 100644 index 0000000..98fa080 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpointHost.java @@ -0,0 +1,33 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.List; + +public interface BuiltinEndpointHost { + double worldTime(); + + default double[] position() { + return new double[] {0, 0, 0}; + } + + default double[] rotation() { + return new double[] {0, 0, 0}; + } + + default int redstoneInput(String face) { + return 0; + } + + default int comparatorInput(String face) { + return 0; + } + + default boolean blockPresent(String face) { + return false; + } + + default void redstoneOutput(String face, int level) {} + + default void showWidgets(String target, List widgets) {} + + void runCommand(String command); +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java new file mode 100644 index 0000000..10cc26c --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinEndpoints.java @@ -0,0 +1,299 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +public final class BuiltinEndpoints { + private static final AtomicBoolean REGISTERED = new AtomicBoolean(); + + private BuiltinEndpoints() {} + + public static void register() { + if (!REGISTERED.compareAndSet(false, true)) { + return; + } + ComputedEndpoints.register("computed:world", endpoint -> endpoint.method( + "time", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate( + LuaValue.valueOf(requireHost(invocation).worldTime())), + invocation -> EndpointResult.immediate(LuaValue.valueOf(6000)), + "Returns the current world day time.") + .method( + "position", + EndpointSignature.of( + List.of(), + List.of(EndpointType.NUMBER, EndpointType.NUMBER, EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> numbers(requireHost(invocation).position()), + invocation -> numbers(new double[] {0.5, 64.5, 0.5}), + "Returns the computer world position.") + .method( + "rotation", + EndpointSignature.of( + List.of(), + List.of(EndpointType.NUMBER, EndpointType.NUMBER, EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> numbers(requireHost(invocation).rotation()), + invocation -> numbers(new double[] {0, 0, 0}), + "Returns the computer rotation in degrees.") + .method( + "block_present", + EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.BOOLEAN)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(LuaValue.valueOf( + requireHost(invocation).blockPresent(argument(invocation, 0)))), + invocation -> EndpointResult.immediate(LuaValue.FALSE), + "Reports whether a block is present at a relative face.")); + ComputedEndpoints.register("computed:redstone", endpoint -> endpoint.method( + "input", + EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(LuaValue.valueOf( + requireHost(invocation).redstoneInput(argument(invocation, 0)))), + invocation -> EndpointResult.immediate(LuaValue.ZERO), + "Reads weak redstone power from a relative face.") + .method( + "comparator", + EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.NUMBER)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(LuaValue.valueOf( + requireHost(invocation).comparatorInput(argument(invocation, 0)))), + invocation -> EndpointResult.immediate(LuaValue.ZERO), + "Reads comparator power from a relative face.") + .method( + "output", + EndpointSignature.of( + List.of(EndpointType.STRING, EndpointType.NUMBER), + List.of()), + EndpointPolicy.computerThread(true, false), + invocation -> { + requireHost(invocation).redstoneOutput( + argument(invocation, 0), + invocation.arguments().get(1).checkint()); + return EndpointResult.immediate(); + }, + null, + "Writes weak redstone power to a relative face.")); + ComputedEndpoints.register("computed:command", endpoint -> endpoint.method( + "run", + EndpointSignature.of(List.of(EndpointType.STRING), List.of()), + EndpointPolicy.computerThread(true, false), + invocation -> { + BuiltinEndpointHost host = requireHost(invocation); + host.runCommand(invocation.arguments().getFirst().tojstring()); + return EndpointResult.immediate(); + }, + null, + "Runs a command through the computer host.")); + ComputedEndpoints.register("computed:widget", endpoint -> endpoint.method( + "text", + EndpointSignature.of(List.of(EndpointType.STRING), List.of(EndpointType.TABLE)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(widget( + invocation.nodeId(), + "text", + Map.of("text", argument(invocation, 0), "alignment", "left"))), + invocation -> EndpointResult.immediate(widget( + invocation.nodeId(), + "text", + Map.of("text", argument(invocation, 0), "alignment", "left"))), + "Creates a text widget value.") + .method( + "clock", + EndpointSignature.of( + List.of(EndpointType.NUMBER, EndpointType.BOOLEAN), + List.of(EndpointType.TABLE)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(clockWidget(invocation)), + invocation -> EndpointResult.immediate(clockWidget(invocation)), + "Creates a clock widget value.") + .method( + "button", + EndpointSignature.of( + List.of(EndpointType.STRING, EndpointType.NUMBER), + List.of(EndpointType.TABLE)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(buttonWidget(invocation)), + invocation -> EndpointResult.immediate(buttonWidget(invocation)), + "Creates a button widget value.") + .method( + "slider", + EndpointSignature.of( + List.of( + EndpointType.NUMBER, + EndpointType.NUMBER, + EndpointType.NUMBER, + EndpointType.NUMBER, + EndpointType.NUMBER), + List.of(EndpointType.TABLE)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(sliderWidget(invocation)), + invocation -> EndpointResult.immediate(sliderWidget(invocation)), + "Creates a slider widget value.") + .method( + "progress", + EndpointSignature.of( + List.of( + EndpointType.NUMBER, + EndpointType.NUMBER, + EndpointType.NUMBER, + EndpointType.NUMBER), + List.of(EndpointType.TABLE)), + EndpointPolicy.computerThread(false, true), + invocation -> EndpointResult.immediate(progressWidget(invocation)), + invocation -> EndpointResult.immediate(progressWidget(invocation)), + "Creates a progress bar widget value.")); + ComputedEndpoints.register("computed:monitor", endpoint -> endpoint.method( + "show", + EndpointSignature.of(List.of(EndpointType.TABLE), List.of()), + EndpointPolicy.computerThread(true, false), + invocation -> { + requireHost(invocation).showWidgets( + invocation.target(), + parseWidgets(invocation.arguments().getFirst().checktable(), invocation.nodeId())); + return EndpointResult.immediate(); + }, + null, + "Shows widget values on the monitor at the endpoint target.")); + } + + private static BuiltinEndpointHost requireHost(EndpointInvocation invocation) { + if (invocation.host() instanceof BuiltinEndpointHost host) { + return host; + } + throw new IllegalStateException("Computer host does not provide built-in endpoint access"); + } + + private static LuaTable widget(UUID nodeId, String type, Map properties) { + LuaTable widget = new LuaTable(); + widget.set("id", nodeId.toString()); + widget.set("type", type); + widget.set("x", 0); + widget.set("y", 0); + widget.set("width", 64); + widget.set("height", 16); + properties.forEach((key, value) -> widget.set(key, toLua(value))); + return widget; + } + + private static LuaTable clockWidget(EndpointInvocation invocation) { + return widget(invocation.nodeId(), "clock", Map.of( + "color", invocation.arguments().get(0).checkint(), + "show_seconds", invocation.arguments().get(1).checkboolean())); + } + + private static LuaTable buttonWidget(EndpointInvocation invocation) { + return widget(invocation.nodeId(), "button", Map.of( + "label", argument(invocation, 0), + "color", invocation.arguments().get(1).checkint())); + } + + private static LuaTable sliderWidget(EndpointInvocation invocation) { + return widget(invocation.nodeId(), "slider", Map.of( + "value", invocation.arguments().get(0).checkdouble(), + "minimum", invocation.arguments().get(1).checkdouble(), + "maximum", invocation.arguments().get(2).checkdouble(), + "color", invocation.arguments().get(3).checkint(), + "step", invocation.arguments().get(4).checkdouble())); + } + + private static LuaTable progressWidget(EndpointInvocation invocation) { + return widget(invocation.nodeId(), "progress", Map.of( + "value", invocation.arguments().get(0).checkdouble(), + "maximum", invocation.arguments().get(1).checkdouble(), + "color", invocation.arguments().get(2).checkint(), + "segments", invocation.arguments().get(3).checkint())); + } + + private static List parseWidgets(LuaTable table, UUID fallbackId) { + java.util.ArrayList widgets = new java.util.ArrayList<>(); + if (!table.get("type").isnil()) { + widgets.add(parseWidget(table, fallbackId)); + return List.copyOf(widgets); + } + int count = Math.min(64, table.length()); + for (int index = 1; index <= count; index++) { + LuaValue value = table.get(index); + if (value.istable() && !value.get("type").isnil()) { + widgets.add(parseWidget(value.checktable(), fallbackId)); + } + } + return List.copyOf(widgets); + } + + private static BuiltinWidget parseWidget(LuaTable table, UUID fallbackId) { + UUID id; + try { + id = UUID.fromString(table.get("id").optjstring(fallbackId.toString())); + } catch (IllegalArgumentException exception) { + id = fallbackId; + } + Map properties = new LinkedHashMap<>(); + copyText(table, properties, "text", ""); + copyText(table, properties, "label", ""); + copyText(table, properties, "alignment", "left"); + copyText(table, properties, "layout_mode", "line"); + copyText(table, properties, "fit", "auto"); + copyNumber(table, properties, "line", 1); + copyNumber(table, properties, "span", 1); + copyNumber(table, properties, "value", 0); + copyNumber(table, properties, "minimum", 0); + copyNumber(table, properties, "maximum", 1); + copyNumber(table, properties, "step", 1); + copyNumber(table, properties, "segments", 0); + properties.put("show_seconds", table.get("show_seconds").optboolean(false)); + return new BuiltinWidget( + id, + table.get("type").checkjstring(), + table.get("x").optint(0), + table.get("y").optint(0), + Math.max(1, table.get("width").optint(64)), + Math.max(1, table.get("height").optint(16)), + table.get("color").optint(0xFFFFFFFF), + properties); + } + + private static void copyText( + LuaTable table, + Map properties, + String key, + String fallback) { + properties.put(key, table.get(key).optjstring(fallback)); + } + + private static void copyNumber( + LuaTable table, + Map properties, + String key, + double fallback) { + properties.put(key, table.get(key).optdouble(fallback)); + } + + private static LuaValue toLua(Object value) { + return switch (value) { + case String text -> LuaValue.valueOf(text); + case Boolean flag -> LuaValue.valueOf(flag); + case Integer number -> LuaValue.valueOf(number); + case Double number -> LuaValue.valueOf(number); + default -> LuaValue.NIL; + }; + } + + private static String argument(EndpointInvocation invocation, int index) { + return invocation.arguments().get(index).checkjstring(); + } + + private static EndpointResult.Immediate numbers(double[] values) { + return EndpointResult.immediate( + LuaValue.valueOf(values[0]), + LuaValue.valueOf(values[1]), + LuaValue.valueOf(values[2])); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinWidget.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinWidget.java new file mode 100644 index 0000000..431e3df --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/BuiltinWidget.java @@ -0,0 +1,19 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.Map; +import java.util.UUID; + +public record BuiltinWidget( + UUID id, + String type, + int x, + int y, + int width, + int height, + int color, + Map properties) { + + public BuiltinWidget { + properties = properties == null ? Map.of() : Map.copyOf(properties); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/ComputedEndpoints.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/ComputedEndpoints.java new file mode 100644 index 0000000..18c783a --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/ComputedEndpoints.java @@ -0,0 +1,38 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +public final class ComputedEndpoints { + private static final Map ENDPOINTS = new ConcurrentHashMap<>(); + + private ComputedEndpoints() {} + + public static EndpointDefinition register(String id, Consumer registration) { + EndpointBuilder builder = new EndpointBuilder(id); + registration.accept(builder); + EndpointDefinition definition = builder.build(); + EndpointDefinition previous = ENDPOINTS.putIfAbsent(definition.id(), definition); + if (previous != null) { + throw new IllegalStateException("Endpoint is already registered: " + definition.id()); + } + return definition; + } + + public static Optional find(String id) { + return Optional.ofNullable(ENDPOINTS.get(id)); + } + + public static List definitions() { + return ENDPOINTS.values().stream() + .sorted(java.util.Comparator.comparing(EndpointDefinition::id)) + .toList(); + } + + static void clearForTests() { + ENDPOINTS.clear(); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointBuilder.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointBuilder.java new file mode 100644 index 0000000..d08b5f9 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointBuilder.java @@ -0,0 +1,41 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; + +public final class EndpointBuilder { + private final String id; + private final Map methods = new LinkedHashMap<>(); + + EndpointBuilder(String id) { + this.id = EndpointIds.requireNamespaced(id, "endpoint"); + } + + public EndpointBuilder method( + String methodId, + EndpointSignature signature, + EndpointPolicy policy, + EndpointHandler handler) { + return method(methodId, signature, policy, handler, null, ""); + } + + public EndpointBuilder method( + String methodId, + EndpointSignature signature, + EndpointPolicy policy, + EndpointHandler handler, + Function previewFixture, + String documentation) { + EndpointMethod method = + new EndpointMethod(methodId, signature, policy, handler, previewFixture, documentation); + if (methods.putIfAbsent(method.id(), method) != null) { + throw new IllegalArgumentException("Duplicate endpoint method: " + id + '/' + method.id()); + } + return this; + } + + EndpointDefinition build() { + return new EndpointDefinition(id, methods); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointDefinition.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointDefinition.java new file mode 100644 index 0000000..ba2002e --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointDefinition.java @@ -0,0 +1,16 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.LinkedHashMap; +import java.util.Map; + +public record EndpointDefinition(String id, Map methods) { + public EndpointDefinition { + id = EndpointIds.requireNamespaced(id, "endpoint"); + methods = methods == null + ? Map.of() + : java.util.Collections.unmodifiableMap(new LinkedHashMap<>(methods)); + if (methods.isEmpty()) { + throw new IllegalArgumentException("Endpoint " + id + " must register at least one method"); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointHandler.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointHandler.java new file mode 100644 index 0000000..a125146 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointHandler.java @@ -0,0 +1,6 @@ +package dev.propulsionteam.computed.lua.endpoint; + +@FunctionalInterface +public interface EndpointHandler { + EndpointResult invoke(EndpointInvocation invocation) throws Exception; +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointIds.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointIds.java new file mode 100644 index 0000000..760fdf9 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointIds.java @@ -0,0 +1,24 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.regex.Pattern; + +final class EndpointIds { + private static final Pattern MEMBER = Pattern.compile("[a-z][a-z0-9_.-]{0,63}"); + private static final Pattern NAMESPACED = Pattern.compile("[a-z0-9_.-]+:[a-z0-9_./-]+"); + + private EndpointIds() {} + + static String requireMember(String id, String kind) { + if (id == null || !MEMBER.matcher(id).matches()) { + throw new IllegalArgumentException("Invalid " + kind + " id: " + id); + } + return id; + } + + static String requireNamespaced(String id, String kind) { + if (id == null || id.length() > 128 || !NAMESPACED.matcher(id).matches()) { + throw new IllegalArgumentException("Invalid " + kind + " id: " + id); + } + return id; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointInvocation.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointInvocation.java new file mode 100644 index 0000000..3b3ca58 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointInvocation.java @@ -0,0 +1,22 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.luaj.vm2.LuaValue; + +public record EndpointInvocation( + UUID computerId, + UUID nodeId, + String target, + List arguments, + boolean preview, + Object host) { + + public EndpointInvocation { + Objects.requireNonNull(computerId, "computerId"); + Objects.requireNonNull(nodeId, "nodeId"); + target = target == null ? "" : target; + arguments = arguments == null ? List.of() : List.copyOf(arguments); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointMethod.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointMethod.java new file mode 100644 index 0000000..8df1608 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointMethod.java @@ -0,0 +1,24 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.Objects; +import java.util.function.Function; + +public record EndpointMethod( + String id, + EndpointSignature signature, + EndpointPolicy policy, + EndpointHandler handler, + Function previewFixture, + String documentation) { + + public EndpointMethod { + id = EndpointIds.requireMember(id, "method"); + Objects.requireNonNull(signature, "signature"); + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(handler, "handler"); + documentation = documentation == null ? "" : documentation; + if (policy.previewAvailable() && previewFixture == null) { + throw new IllegalArgumentException("Preview-enabled endpoint method " + id + " requires a fixture"); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointPolicy.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointPolicy.java new file mode 100644 index 0000000..58c9dc4 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointPolicy.java @@ -0,0 +1,23 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.Objects; + +public record EndpointPolicy( + ExecutionSide executionSide, + boolean yielding, + boolean sideEffect, + boolean previewAvailable) { + + public EndpointPolicy { + Objects.requireNonNull(executionSide, "executionSide"); + } + + public static EndpointPolicy computerThread(boolean sideEffect, boolean previewAvailable) { + return new EndpointPolicy(ExecutionSide.COMPUTER_THREAD, false, sideEffect, previewAvailable); + } + + public enum ExecutionSide { + COMPUTER_THREAD, + SERVER_THREAD + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointResult.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointResult.java new file mode 100644 index 0000000..e4b27e9 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointResult.java @@ -0,0 +1,38 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletionStage; +import org.luaj.vm2.LuaValue; + +public sealed interface EndpointResult permits EndpointResult.Immediate, EndpointResult.Yielded, EndpointResult.Unavailable { + record Immediate(List values) implements EndpointResult { + public Immediate { + values = values == null ? List.of() : List.copyOf(values); + } + } + + record Yielded(CompletionStage continuation) implements EndpointResult { + public Yielded { + Objects.requireNonNull(continuation, "continuation"); + } + } + + record Unavailable(String reason) implements EndpointResult { + public Unavailable { + reason = reason == null || reason.isBlank() ? "endpoint unavailable" : reason; + } + } + + public static Immediate immediate(LuaValue... values) { + return new Immediate(List.of(values)); + } + + public static Yielded yielded(CompletionStage continuation) { + return new Yielded(continuation); + } + + public static Unavailable unavailable(String reason) { + return new Unavailable(reason); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointRuntimeLifecycle.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointRuntimeLifecycle.java new file mode 100644 index 0000000..d941084 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointRuntimeLifecycle.java @@ -0,0 +1,31 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class EndpointRuntimeLifecycle { + private static final List LISTENERS = new CopyOnWriteArrayList<>(); + + private EndpointRuntimeLifecycle() {} + + public static void register(Listener listener) { + if (listener != null) { + LISTENERS.add(listener); + } + } + + public static void tick(UUID computerId, Object host) { + LISTENERS.forEach(listener -> listener.tick(computerId, host)); + } + + public static void unload(UUID computerId, Object host) { + LISTENERS.forEach(listener -> listener.unload(computerId, host)); + } + + public interface Listener { + default void tick(UUID computerId, Object host) {} + + default void unload(UUID computerId, Object host) {} + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointSignature.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointSignature.java new file mode 100644 index 0000000..d8d2fdb --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointSignature.java @@ -0,0 +1,14 @@ +package dev.propulsionteam.computed.lua.endpoint; + +import java.util.List; + +public record EndpointSignature(List arguments, List returns, boolean variadic) { + public EndpointSignature { + arguments = arguments == null ? List.of() : List.copyOf(arguments); + returns = returns == null ? List.of() : List.copyOf(returns); + } + + public static EndpointSignature of(List arguments, List returns) { + return new EndpointSignature(arguments, returns, false); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointType.java b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointType.java new file mode 100644 index 0000000..0b85a07 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/endpoint/EndpointType.java @@ -0,0 +1,10 @@ +package dev.propulsionteam.computed.lua.endpoint; + +public enum EndpointType { + NIL, + NUMBER, + BOOLEAN, + STRING, + TABLE, + ANY +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/BundledLuaLibrary.java b/src/main/java/dev/propulsionteam/computed/lua/node/BundledLuaLibrary.java new file mode 100644 index 0000000..c88cfd7 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/BundledLuaLibrary.java @@ -0,0 +1,187 @@ +package dev.propulsionteam.computed.lua.node; + +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class BundledLuaLibrary { + private static final List ENTRIES = List.of( + new Entry("computed:add", "computed/lua/nodes/math/add.lua"), + new Entry("computed:counter", "computed/lua/nodes/state/counter.lua"), + new Entry("computed:world_time", "computed/lua/nodes/world/time.lua"), + new Entry("computed:block_location", "computed/lua/nodes/world/location.lua"), + new Entry("computed:block_rotation", "computed/lua/nodes/world/rotation.lua"), + new Entry("computed:block_presence", "computed/lua/nodes/world/presence.lua"), + new Entry("computed:redstone_input", "computed/lua/nodes/world/redstone_input.lua"), + new Entry("computed:comparator_read", "computed/lua/nodes/world/comparator.lua"), + new Entry("computed:redstone_emitter", "computed/lua/nodes/world/redstone_output.lua"), + new Entry("computed:command", "computed/lua/nodes/io/command.lua"), + new Entry("computed:text_widget", "computed/lua/nodes/widgets/text.lua"), + new Entry("computed:clock_widget", "computed/lua/nodes/widgets/clock.lua"), + new Entry("computed:button_widget", "computed/lua/nodes/widgets/button.lua"), + new Entry("computed:slider_widget", "computed/lua/nodes/widgets/slider.lua"), + new Entry("computed:progress_bar_widget", "computed/lua/nodes/widgets/progress.lua"), + new Entry("computed:peripheral", "computed/lua/nodes/widgets/monitor.lua"), + new Entry("computed:constant", "computed/lua/nodes/sources/constant.lua"), + new Entry("computed:tick", "computed/lua/nodes/sources/tick.lua"), + new Entry("computed:pulse", "computed/lua/nodes/sources/pulse.lua"), + new Entry("computed:oscillator", "computed/lua/nodes/sources/oscillator.lua"), + new Entry("computed:pass_every_n", "computed/lua/nodes/state/pass_every_n.lua"), + new Entry("computed:delay", "computed/lua/nodes/state/delay.lua"), + new Entry("computed:sample_hold", "computed/lua/nodes/state/sample_hold.lua"), + new Entry("computed:math_random", "computed/lua/nodes/math/random.lua"), + new Entry("computed:math_clamp", "computed/lua/nodes/math/clamp.lua"), + new Entry("computed:math_map", "computed/lua/nodes/math/map.lua"), + new Entry("computed:math_lerp", "computed/lua/nodes/math/lerp.lua"), + new Entry("computed:math_average", "computed/lua/nodes/math/average.lua"), + new Entry("computed:logic_not", "computed/lua/nodes/logic/not.lua"), + new Entry("computed:cmp_approx", "computed/lua/nodes/logic/approximately.lua"), + new Entry("computed:edge_rise", "computed/lua/nodes/logic/edge_rise.lua"), + new Entry("computed:edge_fall", "computed/lua/nodes/logic/edge_fall.lua"), + new Entry("computed:schmitt", "computed/lua/nodes/logic/schmitt.lua"), + new Entry("computed:mux", "computed/lua/nodes/logic/mux.lua"), + new Entry("computed:sr_latch", "computed/lua/nodes/logic/sr_latch.lua"), + new Entry("computed:d_flipflop", "computed/lua/nodes/logic/d_flipflop.lua"), + new Entry("computed:bool_to_level", "computed/lua/nodes/io/bool_to_level.lua"), + new Entry("computed:level_to_bool", "computed/lua/nodes/io/level_to_bool.lua"), + new Entry("computed:display", "computed/lua/nodes/io/display.lua"), + new Entry("computed:text_source", "computed/lua/nodes/text/source.lua"), + new Entry("computed:color_source", "computed/lua/nodes/sources/color.lua"), + new Entry("computed:concatenate_strings", "computed/lua/nodes/text/concatenate.lua"), + new Entry("computed:if_branch", "computed/lua/nodes/flow/if.lua"), + new Entry("computed:switch", "computed/lua/nodes/flow/switch.lua"), + new Entry("computed:rgb_preview", "computed/lua/nodes/io/rgb_preview.lua")); + + private BundledLuaLibrary() {} + + public static Map load() { + Map definitions = new LinkedHashMap<>(); + for (Entry entry : ENTRIES) { + add(definitions, entry.id(), read(entry.resource())); + } + unarySpecs().forEach(spec -> add( + definitions, + spec.id(), + template( + "computed/lua/nodes/templates/unary.lua", + spec.id(), + spec.title(), + spec.category(), + spec.expression()))); + binarySpecs().forEach(spec -> add( + definitions, + spec.id(), + template( + "computed/lua/nodes/templates/binary.lua", + spec.id(), + spec.title(), + spec.category(), + spec.expression()))); + comparisonSpecs().forEach(spec -> add( + definitions, + spec.id(), + template( + "computed/lua/nodes/templates/comparison.lua", + spec.id(), + spec.title(), + spec.category(), + spec.expression()))); + return java.util.Collections.unmodifiableMap(definitions); + } + + private static void add( + Map definitions, + String id, + String source) { + LuaDefinitionSource definition = new LuaDefinitionSource( + 1, + id, + source, + "", + LuaDefinitionSource.Origin.BUNDLED); + definitions.put(definition.id(), definition); + } + + private static String template( + String resource, + String id, + String title, + String category, + String expression) { + return read(resource) + .replace("@ID@", id) + .replace("@TITLE@", title) + .replace("@CATEGORY@", category) + .replace("@EXPRESSION@", expression); + } + + private static List unarySpecs() { + return List.of( + new Spec("computed:math_abs", "Absolute", "math", "math.abs(value)"), + new Spec("computed:math_sqrt", "Square Root", "math", "math.sqrt(math.max(0, value))"), + new Spec("computed:math_floor", "Floor", "math", "math.floor(value)"), + new Spec("computed:math_ceil", "Ceiling", "math", "math.ceil(value)"), + new Spec("computed:math_round", "Round", "math", "math.floor(value + 0.5)"), + new Spec("computed:math_negate", "Negate", "math", "-value"), + new Spec("computed:math_log", "Natural Log", "math", "value > 0 and math.log(value) or 0"), + new Spec("computed:math_log10", "Log 10", "math", "value > 0 and math.log(value, 10) or 0"), + new Spec("computed:math_exp", "Exponent", "math", "math.exp(value)"), + new Spec("computed:math_sign", "Sign", "math", "value > 0 and 1 or (value < 0 and -1 or 0)"), + new Spec("computed:math_sin", "Sine", "math", "math.sin(value)"), + new Spec("computed:math_cos", "Cosine", "math", "math.cos(value)"), + new Spec("computed:math_tan", "Tangent", "math", "math.tan(value)"), + new Spec("computed:quantize_redstone", "Quantize Redstone", "io", "math.max(0, math.min(15, math.floor(value + 0.5)))")); + } + + private static List binarySpecs() { + return List.of( + new Spec("computed:math_add", "Add", "math", "a + b"), + new Spec("computed:math_subtract", "Subtract", "math", "a - b"), + new Spec("computed:math_multiply", "Multiply", "math", "a * b"), + new Spec("computed:math_divide", "Divide", "math", "b == 0 and 0 or a / b"), + new Spec("computed:math_mod", "Modulo", "math", "b == 0 and 0 or a % b"), + new Spec("computed:math_min", "Minimum", "math", "math.min(a, b)"), + new Spec("computed:math_max", "Maximum", "math", "math.max(a, b)"), + new Spec("computed:math_pow", "Power", "math", "a ^ b"), + new Spec( + "computed:math_atan2", + "Atan2", + "math", + "b > 0 and math.atan(a / b) or (b < 0 and (a >= 0 and math.atan(a / b) + math.pi or math.atan(a / b) - math.pi) or (a >= 0 and math.pi / 2 or -math.pi / 2))"), + new Spec("computed:logic_and", "And", "logic", "(a ~= 0 and b ~= 0) and 1 or 0"), + new Spec("computed:logic_or", "Or", "logic", "(a ~= 0 or b ~= 0) and 1 or 0"), + new Spec("computed:logic_xor", "Xor", "logic", "((a ~= 0) ~= (b ~= 0)) and 1 or 0"), + new Spec("computed:logic_nand", "Nand", "logic", "(not (a ~= 0 and b ~= 0)) and 1 or 0"), + new Spec("computed:logic_nor", "Nor", "logic", "(not (a ~= 0 or b ~= 0)) and 1 or 0"), + new Spec("computed:logic_xnor", "Xnor", "logic", "((a ~= 0) == (b ~= 0)) and 1 or 0")); + } + + private static List comparisonSpecs() { + return List.of( + new Spec("computed:cmp_eq", "Equal", "logic", "a == b"), + new Spec("computed:cmp_gt", "Greater Than", "logic", "a > b"), + new Spec("computed:cmp_lt", "Less Than", "logic", "a < b"), + new Spec("computed:cmp_ge", "Greater or Equal", "logic", "a >= b"), + new Spec("computed:cmp_le", "Less or Equal", "logic", "a <= b")); + } + + private static String read(String path) { + ClassLoader loader = BundledLuaLibrary.class.getClassLoader(); + try (InputStream stream = loader.getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("Missing bundled Lua definition: " + path); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Could not read bundled Lua definition: " + path, exception); + } + } + + private record Entry(String id, String resource) {} + + private record Spec(String id, String title, String category, String expression) {} +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/ConnectionType.java b/src/main/java/dev/propulsionteam/computed/lua/node/ConnectionType.java new file mode 100644 index 0000000..a547003 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/ConnectionType.java @@ -0,0 +1,20 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.Locale; + +public enum ConnectionType { + NUMBER, + BOOLEAN, + STRING, + EVENT, + WIDGET, + TABLE; + + public static ConnectionType parse(String value) { + try { + return valueOf(value.toUpperCase(Locale.ROOT)); + } catch (RuntimeException exception) { + throw new LuaDefinitionException("Unknown connection type: " + value); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/FieldControl.java b/src/main/java/dev/propulsionteam/computed/lua/node/FieldControl.java new file mode 100644 index 0000000..ee6f5e2 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/FieldControl.java @@ -0,0 +1,16 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.Locale; + +public enum FieldControl { + VALUE, + SLIDER; + + public static FieldControl parse(String value) { + try { + return valueOf(value.toUpperCase(Locale.ROOT)); + } catch (RuntimeException exception) { + throw new LuaDefinitionException("Unknown field control: " + value); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/FieldType.java b/src/main/java/dev/propulsionteam/computed/lua/node/FieldType.java new file mode 100644 index 0000000..cd2f43d --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/FieldType.java @@ -0,0 +1,21 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.Locale; + +public enum FieldType { + NUMBER, + TEXT, + BOOLEAN, + CHOICE, + COLOR, + DIRECTION, + ITEM; + + public static FieldType parse(String value) { + try { + return valueOf(value.toUpperCase(Locale.ROOT)); + } catch (RuntimeException exception) { + throw new LuaDefinitionException("Unknown field type: " + value); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/IntegrationLuaLibrary.java b/src/main/java/dev/propulsionteam/computed/lua/node/IntegrationLuaLibrary.java new file mode 100644 index 0000000..f282889 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/IntegrationLuaLibrary.java @@ -0,0 +1,59 @@ +package dev.propulsionteam.computed.lua.node; + +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class IntegrationLuaLibrary { + private static final List ENTRIES = List.of( + new Entry("computed:cc_input", "computed/lua/nodes/integration/computercraft/input.lua"), + new Entry("computed:cc_output", "computed/lua/nodes/integration/computercraft/output.lua"), + new Entry("computed:create_kinetic", "computed/lua/nodes/integration/create/kinetic.lua"), + new Entry("computed:create_link_receiver", "computed/lua/nodes/integration/create/link_receiver.lua"), + new Entry("computed:create_link_sender", "computed/lua/nodes/integration/create/link_sender.lua")); + + private IntegrationLuaLibrary() {} + + public static Map load() { + Map definitions = new LinkedHashMap<>(); + for (Entry entry : ENTRIES) { + LuaDefinitionSource source = new LuaDefinitionSource( + 1, + entry.id(), + read(entry.resource()), + "", + LuaDefinitionSource.Origin.INTEGRATION); + definitions.put(source.id(), source); + } + return java.util.Collections.unmodifiableMap(definitions); + } + + public static String unavailableReason(String definitionId) { + if (definitionId.startsWith("computed:cc_") + && !net.neoforged.fml.ModList.get().isLoaded("computercraft")) { + return "CC:Tweaked is not installed"; + } + if (definitionId.startsWith("computed:create_") + && !net.neoforged.fml.ModList.get().isLoaded("create")) { + return "Create is not installed"; + } + return ""; + } + + private static String read(String path) { + try (InputStream stream = IntegrationLuaLibrary.class.getClassLoader().getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("Missing integration Lua definition: " + path); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Could not read integration Lua definition: " + path, exception); + } + } + + private record Entry(String id, String resource) {} +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionException.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionException.java new file mode 100644 index 0000000..eac58ed --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionException.java @@ -0,0 +1,11 @@ +package dev.propulsionteam.computed.lua.node; + +public final class LuaDefinitionException extends RuntimeException { + public LuaDefinitionException(String message) { + super(message); + } + + public LuaDefinitionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionFiles.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionFiles.java new file mode 100644 index 0000000..8f7f89d --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionFiles.java @@ -0,0 +1,32 @@ +package dev.propulsionteam.computed.lua.node; + +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public final class LuaDefinitionFiles { + private LuaDefinitionFiles() {} + + public static Path export(Path configDirectory, LuaDefinitionSource definition) throws IOException { + Path root = configDirectory.toAbsolutePath().normalize().resolve("computed").resolve("nodes"); + String fileName = definition.id().replace(':', '_').replace('/', '_') + ".lua"; + Path target = root.resolve(fileName).normalize(); + if (!target.startsWith(root)) { + throw new IllegalArgumentException("Definition path escapes the Computed node directory"); + } + Files.createDirectories(root); + Files.writeString(target, definition.source(), StandardCharsets.UTF_8); + return target; + } + + public static String importSource(Path configDirectory, String fileName) throws IOException { + Path root = configDirectory.toAbsolutePath().normalize().resolve("computed").resolve("nodes"); + Path target = root.resolve(fileName).normalize(); + if (!target.startsWith(root) || !target.getFileName().toString().endsWith(".lua")) { + throw new IllegalArgumentException("Lua import must stay inside config/computed/nodes"); + } + return Files.readString(target, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLibrary.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLibrary.java new file mode 100644 index 0000000..a35159c --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLibrary.java @@ -0,0 +1,135 @@ +package dev.propulsionteam.computed.lua.node; + +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +public final class LuaDefinitionLibrary { + private final LuaSourceCompiler compiler = new LuaSourceCompiler(); + private final LuaDefinitionLoader loader = new LuaDefinitionLoader(); + private final Map sources = new LinkedHashMap<>(); + private final Map schemas = new LinkedHashMap<>(); + + public LuaDefinitionLibrary(Map initialDefinitions) { + if (initialDefinitions != null) { + initialDefinitions.values().forEach(this::addInitial); + } + } + + public LuaLibraryUpdate importSource( + int apiVersion, + String source, + boolean confirmReplacement, + Predicate permission) { + Objects.requireNonNull(permission, "permission"); + LuaDefinitionSource candidate = source(apiVersion, source); + if (!permission.test(candidate.id())) { + throw new SecurityException("Not permitted to author Lua definition " + candidate.id()); + } + LuaDefinitionSource existing = sources.get(candidate.id()); + if (existing != null && existing.hash().equals(candidate.hash())) { + return new LuaLibraryUpdate( + LuaLibraryUpdate.Status.UNCHANGED, + existing, + List.of(), + List.of(), + "Definition id and hash already exist"); + } + if (existing != null && !confirmReplacement) { + return new LuaLibraryUpdate( + LuaLibraryUpdate.Status.CONFIRMATION_REQUIRED, + candidate, + List.of(), + List.of(), + "Replacing the same id with different source requires confirmation"); + } + if (existing == null && sources.size() >= dev.propulsionteam.computed.graph.ComputedProgramV3.MAX_EMBEDDED_DEFINITIONS) { + throw new IllegalArgumentException("Embedded Lua definition limit reached"); + } + LuaNodeDefinition nextSchema = validate(candidate); + List retained = new ArrayList<>(); + List removed = new ArrayList<>(); + if (existing != null) { + comparePorts(schemas.get(existing.id()), nextSchema, retained, removed); + } + sources.put(candidate.id(), candidate); + schemas.put(candidate.id(), nextSchema); + return new LuaLibraryUpdate( + existing == null ? LuaLibraryUpdate.Status.ADDED : LuaLibraryUpdate.Status.REPLACED, + candidate, + retained, + removed, + existing == null ? "Definition added" : "Definition replaced"); + } + + public Map sources() { + return java.util.Collections.unmodifiableMap(new LinkedHashMap<>(sources)); + } + + public LuaNodeDefinition schema(String id) { + LuaNodeDefinition schema = schemas.get(id); + if (schema == null) { + throw new IllegalArgumentException("Unknown Lua definition: " + id); + } + return schema; + } + + private LuaDefinitionSource source(int apiVersion, String source) { + var compiled = compiler.compile(apiVersion, source); + LuaNodeDefinition definition = loader.load(compiled, new LuaSandbox()); + return new LuaDefinitionSource( + apiVersion, + definition.id(), + source, + compiled.sourceHash(), + LuaDefinitionSource.Origin.EMBEDDED); + } + + private LuaNodeDefinition validate(LuaDefinitionSource source) { + var compiled = compiler.compile(source.apiVersion(), source.source()); + if (!compiled.sourceHash().equals(source.hash())) { + throw new LuaDefinitionException("Definition hash mismatch for " + source.id()); + } + LuaNodeDefinition definition = loader.load(compiled, new LuaSandbox()); + if (!definition.id().equals(source.id())) { + throw new LuaDefinitionException("Definition source id does not match library id"); + } + return definition; + } + + private void addInitial(LuaDefinitionSource source) { + LuaNodeDefinition schema = validate(source); + sources.put(source.id(), source); + schemas.put(source.id(), schema); + } + + private static void comparePorts( + LuaNodeDefinition previous, + LuaNodeDefinition next, + List retained, + List removed) { + Set nextPorts = portIdentities(next); + for (String identity : portIdentities(previous)) { + if (nextPorts.contains(identity)) { + retained.add(identity); + } else { + removed.add(identity); + } + } + } + + private static Set portIdentities(LuaNodeDefinition definition) { + Set identities = new LinkedHashSet<>(); + definition.inputs().forEach(port -> identities.add("input:" + port.id() + ':' + port.type().name())); + definition.outputs().forEach(port -> identities.add("output:" + port.id() + ':' + port.type().name())); + return identities; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLoader.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLoader.java new file mode 100644 index 0000000..f06ee80 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLoader.java @@ -0,0 +1,264 @@ +package dev.propulsionteam.computed.lua.node; + +import dev.propulsionteam.computed.lua.compiler.LuaCompiledSource; +import dev.propulsionteam.computed.lua.sandbox.LuaInstructionBudget; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.luaj.vm2.LuaClosure; +import org.luaj.vm2.LuaFunction; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; +import org.luaj.vm2.lib.VarArgFunction; + +public final class LuaDefinitionLoader { + public LuaNodeDefinition load(LuaCompiledSource source, LuaSandbox sandbox) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(sandbox, "sandbox"); + LuaTable environment = sandbox.createEnvironment(); + Holder holder = new Holder(source); + LuaTable computed = new LuaTable(); + computed.set("node", new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + if (holder.builder != null) { + throw new LuaDefinitionException("A source file may define exactly one node"); + } + holder.builder = new Builder( + args.arg(1).checkint(), + args.arg(2).checkjstring(), + args.arg(3).checkjstring(), + source.sourceHash()); + return holder.builder.table; + } + }); + environment.set("computed", computed); + LuaValue returned; + try (LuaInstructionBudget.Scope ignored = sandbox.budget().beginInvocation()) { + returned = new LuaClosure(source.prototype(), environment).call(); + } catch (LuaDefinitionException exception) { + throw exception; + } catch (StackOverflowError error) { + throw new LuaDefinitionException("Lua definition exceeded the recursion limit", error); + } catch (RuntimeException exception) { + throw new LuaDefinitionException("Lua definition evaluation failed: " + exception.getMessage(), exception); + } + if (holder.builder == null) { + throw new LuaDefinitionException("Lua source did not call computed.node"); + } + if (returned != holder.builder.table) { + throw new LuaDefinitionException("Lua source must return the node created by computed.node"); + } + return holder.builder.build(); + } + + private static final class Holder { + private final LuaCompiledSource source; + private Builder builder; + + private Holder(LuaCompiledSource source) { + this.source = source; + } + } + + private static final class Builder { + private final int apiVersion; + private final String id; + private final String title; + private final String sourceHash; + private final LuaTable table = new LuaTable(); + private final List inputs = new ArrayList<>(); + private final List outputs = new ArrayList<>(); + private final List fields = new ArrayList<>(); + private final Map stateDefaults = new LinkedHashMap<>(); + private final Map eventHandlers = new LinkedHashMap<>(); + private String category = "utility"; + private NodeStyle style = NodeStyle.STANDARD; + private LuaExecutionPolicy executionPolicy = LuaExecutionPolicy.INPUT; + private LuaFunction onRun; + private boolean built; + + private Builder(int apiVersion, String id, String title, String sourceHash) { + this.apiVersion = apiVersion; + this.id = id; + this.title = title; + this.sourceHash = sourceHash; + installMethods(); + } + + private void installMethods() { + table.set("category", method(args -> { + category = requireText(args.arg(2), "category"); + return table; + })); + table.set("style", method(args -> { + style = NodeStyle.parse(args.arg(2).checkjstring()); + return table; + })); + table.set("input", method(args -> { + inputs.add(port(args, "input")); + return table; + })); + table.set("output", method(args -> { + outputs.add(port(args, "output")); + return table; + })); + table.set("field", method(args -> { + fields.add(field(args)); + return table; + })); + table.set("state", method(args -> { + String stateId = LuaSchemaNames.requireStableId(args.arg(2).checkjstring(), "state"); + if (stateDefaults.putIfAbsent(stateId, args.arg(3)) != null) { + throw new LuaDefinitionException("Duplicate state id: " + stateId); + } + return table; + })); + table.set("execution", method(args -> { + executionPolicy = LuaExecutionPolicy.parse(args.arg(2).checkjstring()); + return table; + })); + table.set("on_run", method(args -> { + if (onRun != null) { + throw new LuaDefinitionException("on_run may only be declared once"); + } + onRun = args.arg(2).checkfunction(); + return table; + })); + table.set("on_event", method(args -> { + String eventName = LuaSchemaNames.requireStableId(args.arg(2).checkjstring(), "event"); + if (eventHandlers.putIfAbsent(eventName, args.arg(3).checkfunction()) != null) { + throw new LuaDefinitionException("Duplicate event handler: " + eventName); + } + return table; + })); + } + + private VarArgFunction method(java.util.function.Function action) { + return new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + ensureMutable(); + if (args.arg1() != table) { + throw new LuaDefinitionException("Definition methods must be called with ':'"); + } + return action.apply(args); + } + }; + } + + private LuaPortSchema port(Varargs args, String kind) { + String portId = args.arg(2).checkjstring(); + ConnectionType type = ConnectionType.parse(args.arg(3).checkjstring()); + LuaTable options = options(args.arg(4)); + boolean required = options.get("required").optboolean(true); + return new LuaPortSchema(portId, type, required, options.get("default")); + } + + private LuaFieldSchema field(Varargs args) { + String fieldId = args.arg(2).checkjstring(); + FieldType type = FieldType.parse(args.arg(3).checkjstring()); + LuaTable options = options(args.arg(4)); + List choices = new ArrayList<>(); + LuaValue choiceValue = options.get("choices"); + if (!choiceValue.isnil()) { + LuaTable choiceTable = choiceValue.checktable(); + for (int index = 1; index <= choiceTable.length(); index++) { + choices.add(choiceTable.get(index).checkjstring()); + } + } + Double minimum = optionalNumber(options.get("min")); + Double maximum = optionalNumber(options.get("max")); + String label = options.get("label").isnil() + ? null + : requireText(options.get("label"), "Field label"); + FieldControl control = options.get("control").isnil() + ? FieldControl.VALUE + : FieldControl.parse(options.get("control").checkjstring()); + Double step = optionalNumber(options.get("step")); + String visibleWhenField = null; + String visibleWhenValue = null; + LuaValue visibleWhen = options.get("visible_when"); + if (!visibleWhen.isnil()) { + LuaTable condition = visibleWhen.checktable(); + visibleWhenField = condition.get("field").checkjstring(); + visibleWhenValue = condition.get("equals").checkjstring(); + } + LuaFieldSchema schema = new LuaFieldSchema( + fieldId, + type, + options.get("default"), + choices, + minimum, + maximum, + label, + control, + step, + visibleWhenField, + visibleWhenValue); + String error = LuaFieldValues.validationError(schema, schema.defaultValue()); + if (error != null) { + throw new LuaDefinitionException(error); + } + return schema; + } + + private LuaNodeDefinition build() { + ensureMutable(); + for (LuaFieldSchema field : fields) { + if (field.visibleWhenField() != null + && fields.stream().noneMatch(candidate -> + candidate.id().equals(field.visibleWhenField()))) { + throw new LuaDefinitionException( + "Field " + field.id() + " visibility references unknown field " + + field.visibleWhenField()); + } + if (field.id().equals(field.visibleWhenField())) { + throw new LuaDefinitionException( + "Field " + field.id() + " cannot control its own visibility"); + } + } + built = true; + return new LuaNodeDefinition( + apiVersion, + id, + title, + category, + style, + executionPolicy, + inputs, + outputs, + fields, + stateDefaults, + onRun, + eventHandlers, + sourceHash); + } + + private void ensureMutable() { + if (built) { + throw new LuaDefinitionException("Node definition schema is immutable"); + } + } + + private static LuaTable options(LuaValue value) { + return value.isnil() ? new LuaTable() : value.checktable(); + } + + private static Double optionalNumber(LuaValue value) { + return value.isnil() ? null : value.checkdouble(); + } + + private static String requireText(LuaValue value, String label) { + String text = value.checkjstring().strip(); + if (text.isEmpty() || text.length() > 128) { + throw new LuaDefinitionException(label + " must contain between 1 and 128 characters"); + } + return text; + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaExecutionPolicy.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaExecutionPolicy.java new file mode 100644 index 0000000..1b98792 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaExecutionPolicy.java @@ -0,0 +1,18 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.Locale; + +public enum LuaExecutionPolicy { + INPUT, + TICK, + STEP, + EVENT; + + public static LuaExecutionPolicy parse(String value) { + try { + return valueOf(value.toUpperCase(Locale.ROOT)); + } catch (RuntimeException exception) { + throw new LuaDefinitionException("Unknown execution policy: " + value); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaFieldSchema.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaFieldSchema.java new file mode 100644 index 0000000..a0a18db --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaFieldSchema.java @@ -0,0 +1,75 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.List; +import java.util.Objects; +import org.luaj.vm2.LuaValue; + +public record LuaFieldSchema( + String id, + FieldType type, + LuaValue defaultValue, + List choices, + Double minimum, + Double maximum, + String label, + FieldControl control, + Double step, + String visibleWhenField, + String visibleWhenValue) { + + public LuaFieldSchema { + id = LuaSchemaNames.requireStableId(id, "field"); + Objects.requireNonNull(type, "type"); + defaultValue = defaultValue == null ? LuaValue.NIL : defaultValue; + choices = choices == null ? List.of() : List.copyOf(choices); + label = label == null || label.isBlank() ? readableLabel(id) : label.strip(); + control = control == null ? FieldControl.VALUE : control; + visibleWhenField = visibleWhenField == null || visibleWhenField.isBlank() + ? null + : LuaSchemaNames.requireStableId(visibleWhenField, "visibility field"); + visibleWhenValue = visibleWhenValue == null ? null : visibleWhenValue; + if ((visibleWhenField == null) != (visibleWhenValue == null)) { + throw new LuaDefinitionException( + "Field visibility requires both a controlling field and expected value"); + } + if (label.length() > 64) { + throw new LuaDefinitionException("Field " + id + " label exceeds 64 characters"); + } + if (minimum != null && !Double.isFinite(minimum) + || maximum != null && !Double.isFinite(maximum)) { + throw new LuaDefinitionException("Field " + id + " range must be finite"); + } + if (minimum != null && maximum != null && minimum > maximum) { + throw new LuaDefinitionException("Field " + id + " has a minimum greater than its maximum"); + } + if (step != null && (!Double.isFinite(step) || step <= 0)) { + throw new LuaDefinitionException("Field " + id + " step must be positive and finite"); + } + if (type == FieldType.CHOICE && choices.isEmpty()) { + throw new LuaDefinitionException("Choice field " + id + " must declare at least one choice"); + } + if (control == FieldControl.SLIDER + && (type != FieldType.NUMBER + || minimum == null + || maximum == null + || maximum <= minimum)) { + throw new LuaDefinitionException( + "Slider field " + id + " requires a numeric min smaller than max"); + } + } + + private static String readableLabel(String id) { + String[] words = id.replace('-', '_').split("_+"); + StringBuilder label = new StringBuilder(); + for (String word : words) { + if (word.isEmpty()) { + continue; + } + if (!label.isEmpty()) { + label.append(' '); + } + label.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + return label.isEmpty() ? id : label.toString(); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaFieldValues.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaFieldValues.java new file mode 100644 index 0000000..dc0d7cd --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaFieldValues.java @@ -0,0 +1,111 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.Locale; +import java.util.Set; +import net.minecraft.resources.ResourceLocation; +import org.luaj.vm2.LuaValue; + +public final class LuaFieldValues { + private static final Set DIRECTIONS = Set.of( + "front", + "back", + "left", + "right", + "up", + "down", + "north", + "south", + "east", + "west"); + + private LuaFieldValues() {} + + public static LuaValue normalize(LuaFieldSchema schema, LuaValue value) { + LuaValue candidate = value == null || value.isnil() ? schema.defaultValue() : value; + if (candidate.isnil()) { + return candidate; + } + return switch (schema.type()) { + case NUMBER -> normalizeNumber(schema, candidate); + case TEXT -> LuaValue.valueOf(candidate.checkjstring()); + case BOOLEAN -> LuaValue.valueOf(candidate.checkboolean()); + case CHOICE -> normalizeChoice(schema, candidate); + case COLOR -> LuaValue.valueOf((double) normalizeColor(candidate)); + case DIRECTION -> normalizeDirection(candidate); + case ITEM -> normalizeItem(candidate); + }; + } + + public static String validationError(LuaFieldSchema schema, LuaValue value) { + try { + LuaValue normalized = normalize(schema, value); + if (!value.isnil() && !equivalent(value, normalized)) { + return "field " + schema.id() + " is outside its declared constraints"; + } + return null; + } catch (RuntimeException exception) { + return "field " + schema.id() + " is invalid: " + exception.getMessage(); + } + } + + private static LuaValue normalizeNumber(LuaFieldSchema schema, LuaValue value) { + double number = value.checkdouble(); + if (!Double.isFinite(number)) { + throw new LuaDefinitionException("number must be finite"); + } + if (schema.minimum() != null) { + number = Math.max(schema.minimum(), number); + } + if (schema.maximum() != null) { + number = Math.min(schema.maximum(), number); + } + if (schema.step() != null) { + double origin = schema.minimum() == null ? 0 : schema.minimum(); + number = origin + Math.round((number - origin) / schema.step()) * schema.step(); + if (schema.minimum() != null) { + number = Math.max(schema.minimum(), number); + } + if (schema.maximum() != null) { + number = Math.min(schema.maximum(), number); + } + } + return LuaValue.valueOf(number); + } + + private static LuaValue normalizeChoice(LuaFieldSchema schema, LuaValue value) { + String choice = value.checkjstring(); + if (!schema.choices().contains(choice)) { + throw new LuaDefinitionException("choice is not declared"); + } + return LuaValue.valueOf(choice); + } + + private static long normalizeColor(LuaValue value) { + double number = value.checkdouble(); + if (!Double.isFinite(number) || number < 0 || number > 0xffffffffL) { + throw new LuaDefinitionException("color must be an unsigned 32-bit ARGB value"); + } + return (long) number; + } + + private static LuaValue normalizeDirection(LuaValue value) { + String direction = value.checkjstring().toLowerCase(Locale.ROOT); + if (!DIRECTIONS.contains(direction)) { + throw new LuaDefinitionException("unknown direction " + direction); + } + return LuaValue.valueOf(direction); + } + + private static LuaValue normalizeItem(LuaValue value) { + String item = value.checkjstring(); + ResourceLocation.parse(item); + return LuaValue.valueOf(item); + } + + private static boolean equivalent(LuaValue first, LuaValue second) { + if (first.isnumber() && second.isnumber()) { + return Double.compare(first.todouble(), second.todouble()) == 0; + } + return first.raweq(second); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaLibraryUpdate.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaLibraryUpdate.java new file mode 100644 index 0000000..1323fc6 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaLibraryUpdate.java @@ -0,0 +1,25 @@ +package dev.propulsionteam.computed.lua.node; + +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import java.util.List; + +public record LuaLibraryUpdate( + Status status, + LuaDefinitionSource definition, + List retainedPorts, + List removedPorts, + String message) { + + public LuaLibraryUpdate { + retainedPorts = retainedPorts == null ? List.of() : List.copyOf(retainedPorts); + removedPorts = removedPorts == null ? List.of() : List.copyOf(removedPorts); + message = message == null ? "" : message; + } + + public enum Status { + ADDED, + UNCHANGED, + REPLACED, + CONFIRMATION_REQUIRED + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaNodeDefinition.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaNodeDefinition.java new file mode 100644 index 0000000..d6efe4b --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaNodeDefinition.java @@ -0,0 +1,71 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.luaj.vm2.LuaFunction; +import org.luaj.vm2.LuaValue; + +public record LuaNodeDefinition( + int apiVersion, + String id, + String title, + String category, + NodeStyle style, + LuaExecutionPolicy executionPolicy, + List inputs, + List outputs, + List fields, + Map stateDefaults, + LuaFunction onRun, + Map eventHandlers, + String sourceHash) { + + public LuaNodeDefinition { + if (apiVersion != 1) { + throw new LuaDefinitionException("Unsupported Lua node API version: " + apiVersion); + } + id = LuaSchemaNames.requireDefinitionId(id); + title = title == null ? "" : title.strip(); + if (title.isEmpty() || title.length() > 96) { + throw new LuaDefinitionException("Node title must contain between 1 and 96 characters"); + } + category = category == null || category.isBlank() ? "utility" : category; + Objects.requireNonNull(style, "style"); + Objects.requireNonNull(executionPolicy, "executionPolicy"); + inputs = inputs == null ? List.of() : List.copyOf(inputs); + outputs = outputs == null ? List.of() : List.copyOf(outputs); + fields = fields == null ? List.of() : List.copyOf(fields); + stateDefaults = stateDefaults == null + ? Map.of() + : java.util.Collections.unmodifiableMap(new LinkedHashMap<>(stateDefaults)); + eventHandlers = eventHandlers == null + ? Map.of() + : java.util.Collections.unmodifiableMap(new LinkedHashMap<>(eventHandlers)); + sourceHash = sourceHash == null ? "" : sourceHash; + validateUniqueIds(inputs, outputs, fields, stateDefaults); + if (onRun == null && eventHandlers.isEmpty()) { + throw new LuaDefinitionException("Node " + id + " must declare on_run or on_event"); + } + } + + private static void validateUniqueIds( + List inputs, + List outputs, + List fields, + Map states) { + Map owners = new LinkedHashMap<>(); + inputs.forEach(schema -> claim(owners, schema.id(), "input")); + outputs.forEach(schema -> claim(owners, schema.id(), "output")); + fields.forEach(schema -> claim(owners, schema.id(), "field")); + states.keySet().forEach(id -> claim(owners, id, "state")); + } + + private static void claim(Map owners, String id, String owner) { + String previous = owners.putIfAbsent(owner + ':' + id, owner); + if (previous != null) { + throw new LuaDefinitionException("Duplicate " + owner + " id: " + id); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaPortSchema.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaPortSchema.java new file mode 100644 index 0000000..fdca765 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaPortSchema.java @@ -0,0 +1,12 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.Objects; +import org.luaj.vm2.LuaValue; + +public record LuaPortSchema(String id, ConnectionType type, boolean required, LuaValue defaultValue) { + public LuaPortSchema { + id = LuaSchemaNames.requireStableId(id, "port"); + Objects.requireNonNull(type, "type"); + defaultValue = defaultValue == null ? LuaValue.NIL : defaultValue; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/LuaSchemaNames.java b/src/main/java/dev/propulsionteam/computed/lua/node/LuaSchemaNames.java new file mode 100644 index 0000000..30d034c --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/LuaSchemaNames.java @@ -0,0 +1,25 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.regex.Pattern; + +final class LuaSchemaNames { + private static final Pattern STABLE_ID = Pattern.compile("[a-z][a-z0-9_.-]{0,63}"); + private static final Pattern DEFINITION_ID = + Pattern.compile("[a-z0-9_.-]+:[a-z0-9_./-]+"); + + private LuaSchemaNames() {} + + static String requireStableId(String value, String kind) { + if (value == null || !STABLE_ID.matcher(value).matches()) { + throw new LuaDefinitionException("Invalid " + kind + " id: " + value); + } + return value; + } + + static String requireDefinitionId(String value) { + if (value == null || value.length() > 128 || !DEFINITION_ID.matcher(value).matches()) { + throw new LuaDefinitionException("Invalid node definition id: " + value); + } + return value; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/node/NodeStyle.java b/src/main/java/dev/propulsionteam/computed/lua/node/NodeStyle.java new file mode 100644 index 0000000..44ed958 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/node/NodeStyle.java @@ -0,0 +1,18 @@ +package dev.propulsionteam.computed.lua.node; + +import java.util.Locale; + +public enum NodeStyle { + STANDARD, + COMPACT, + SOURCE, + SINK; + + public static NodeStyle parse(String value) { + try { + return valueOf(value.toUpperCase(Locale.ROOT)); + } catch (RuntimeException exception) { + throw new LuaDefinitionException("Unknown node style: " + value); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaComputerRuntime.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaComputerRuntime.java new file mode 100644 index 0000000..91c8aee --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaComputerRuntime.java @@ -0,0 +1,106 @@ +package dev.propulsionteam.computed.lua.runtime; + +import dev.propulsionteam.computed.lua.compiler.LuaCompiledSource; +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.node.LuaDefinitionLoader; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.sandbox.LuaInstructionBudget; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public final class LuaComputerRuntime { + private final UUID computerId; + private final LuaSourceCompiler compiler = new LuaSourceCompiler(); + private final LuaDefinitionLoader definitionLoader = new LuaDefinitionLoader(); + private final LuaInstructionBudget budget; + private final LuaSandbox sandbox; + private final Object endpointHost; + private final Map instances = new LinkedHashMap<>(); + private long tick; + private long graphStep; + + public LuaComputerRuntime(UUID computerId) { + this(computerId, new LuaInstructionBudget(), null); + } + + public LuaComputerRuntime(UUID computerId, LuaInstructionBudget budget) { + this(computerId, budget, null); + } + + public LuaComputerRuntime(UUID computerId, LuaInstructionBudget budget, Object endpointHost) { + this.computerId = Objects.requireNonNull(computerId, "computerId"); + this.budget = Objects.requireNonNull(budget, "budget"); + this.endpointHost = endpointHost; + sandbox = new LuaSandbox(budget); + } + + public LuaNodeInstance createNode(UUID nodeId, int apiVersion, String source) { + Objects.requireNonNull(nodeId, "nodeId"); + LuaCompiledSource compiled = compiler.compile(apiVersion, source); + LuaNodeDefinition definition = definitionLoader.load(compiled, sandbox); + LuaNodeInstance instance = new LuaNodeInstance( + computerId, + nodeId, + sandbox, + definition, + endpointHost, + source.contains("endpoint")); + LuaNodeInstance previous = instances.putIfAbsent(nodeId, instance); + if (previous != null) { + throw new IllegalStateException("Lua node instance is already registered: " + nodeId); + } + return instance; + } + + public LuaNodeInstance replaceNode(UUID nodeId, int apiVersion, String source) { + LuaNodeInstance previous = instances.remove(nodeId); + if (previous != null) { + previous.cancelYield(); + } + return createNode(nodeId, apiVersion, source); + } + + public void removeNode(UUID nodeId) { + LuaNodeInstance instance = instances.remove(nodeId); + if (instance != null) { + instance.cancelYield(); + } + } + + public Optional node(UUID nodeId) { + return Optional.ofNullable(instances.get(nodeId)); + } + + public void beginTick(long tick) { + this.tick = Math.max(0, tick); + graphStep = 0; + budget.beginTick(); + dev.propulsionteam.computed.lua.endpoint.EndpointRuntimeLifecycle.tick(computerId, endpointHost); + } + + public long nextGraphStep() { + return ++graphStep; + } + + public long graphStep() { + return graphStep; + } + + public long tick() { + return tick; + } + + public LuaSandbox sandbox() { + return sandbox; + } + + public void unload() { + instances.values().forEach(LuaNodeInstance::cancelYield); + instances.clear(); + dev.propulsionteam.computed.lua.endpoint.EndpointRuntimeLifecycle.unload(computerId, endpointHost); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaEndpointProxy.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaEndpointProxy.java new file mode 100644 index 0000000..7d2995d --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaEndpointProxy.java @@ -0,0 +1,172 @@ +package dev.propulsionteam.computed.lua.runtime; + +import dev.propulsionteam.computed.lua.endpoint.ComputedEndpoints; +import dev.propulsionteam.computed.lua.endpoint.EndpointDefinition; +import dev.propulsionteam.computed.lua.endpoint.EndpointInvocation; +import dev.propulsionteam.computed.lua.endpoint.EndpointMethod; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.endpoint.EndpointType; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.luaj.vm2.LuaError; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; +import org.luaj.vm2.lib.VarArgFunction; + +final class LuaEndpointProxy { + private LuaEndpointProxy() {} + + static LuaTable create(PendingLuaInvocation pending, String endpointId, String target) { + EndpointDefinition endpoint = ComputedEndpoints.find(endpointId) + .orElseThrow(() -> new LuaError("Unknown endpoint: " + endpointId)); + LuaTable proxy = new LuaTable(); + boolean boundProxy = endpoint.methods().containsKey("methods") + && endpoint.methods().containsKey("call"); + proxy.set("methods", new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + requireSelf(proxy, args); + if (boundProxy) { + return LuaEndpointProxy.call( + pending, + endpointId, + target, + endpoint.methods().get("methods"), + List.of()); + } + LuaTable methods = new LuaTable(); + int index = 1; + for (String method : endpoint.methods().keySet()) { + methods.set(index++, method); + } + return methods; + } + }); + proxy.set("call", new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + requireSelf(proxy, args); + if (boundProxy) { + List arguments = new ArrayList<>(); + for (int index = 2; index <= args.narg(); index++) { + arguments.add(args.arg(index)); + } + return LuaEndpointProxy.call( + pending, + endpointId, + target, + endpoint.methods().get("call"), + arguments); + } + String methodId = args.arg(2).checkjstring(); + EndpointMethod method = endpoint.methods().get(methodId); + if (method == null) { + throw new LuaError("Unknown endpoint method: " + endpointId + '/' + methodId); + } + List arguments = new ArrayList<>(); + for (int index = 3; index <= args.narg(); index++) { + arguments.add(args.arg(index)); + } + return LuaEndpointProxy.call(pending, endpointId, target, method, arguments); + } + }); + return proxy; + } + + private static Varargs call( + PendingLuaInvocation pending, + String endpointId, + String target, + EndpointMethod method, + List arguments) { + validateArguments(method, arguments); + EndpointInvocation invocation = + new EndpointInvocation( + pending.computerId(), + pending.nodeId(), + target, + arguments, + pending.preview(), + pending.endpointHost()); + EndpointResult result = LuaEndpointProxy.invoke(method, invocation, pending.preview()); + return switch (result) { + case EndpointResult.Immediate immediate -> { + validateReturns(method, immediate.values()); + yield values(immediate.values()); + } + case EndpointResult.Unavailable unavailable -> + throw new LuaError(unavailable.reason()); + case EndpointResult.Yielded yielded -> { + if (!method.policy().yielding()) { + throw new LuaError("Endpoint returned a continuation but is not declared yielding"); + } + pending.yieldFor(yielded.continuation()); + yield pending.sandbox().globals().yield(LuaValue.NIL); + } + }; + } + + private static EndpointResult invoke( + EndpointMethod method, + EndpointInvocation invocation, + boolean preview) { + try { + if (preview) { + if (!method.policy().previewAvailable()) { + return EndpointResult.unavailable("Endpoint method is unavailable in previews"); + } + return method.previewFixture().apply(invocation); + } + return method.handler().invoke(invocation); + } catch (LuaError error) { + throw error; + } catch (Exception exception) { + throw new LuaError("Endpoint call failed: " + exception.getMessage()); + } + } + + private static void validateArguments(EndpointMethod method, List arguments) { + List expected = method.signature().arguments(); + if (!method.signature().variadic() && arguments.size() != expected.size()) { + throw new LuaError( + "Endpoint method " + method.id() + " expects " + expected.size() + " arguments"); + } + if (method.signature().variadic() && arguments.size() < expected.size()) { + throw new LuaError( + "Endpoint method " + method.id() + " expects at least " + expected.size() + " arguments"); + } + for (int index = 0; index < expected.size(); index++) { + if (!LuaValueValidator.matches(expected.get(index), arguments.get(index))) { + throw new LuaError("Endpoint argument " + (index + 1) + " must be " + + expected.get(index).name().toLowerCase()); + } + } + } + + private static Varargs values(List values) { + return LuaValue.varargsOf(values.toArray(LuaValue[]::new)); + } + + private static void validateReturns(EndpointMethod method, List values) { + List expected = method.signature().returns(); + if (values.size() != expected.size()) { + throw new LuaError( + "Endpoint method " + method.id() + " returned " + values.size() + " values; expected " + + expected.size()); + } + for (int index = 0; index < expected.size(); index++) { + if (!LuaValueValidator.matches(expected.get(index), values.get(index))) { + throw new LuaError("Endpoint return " + (index + 1) + " must be " + + expected.get(index).name().toLowerCase()); + } + } + } + + private static void requireSelf(LuaTable proxy, Varargs args) { + if (args.arg1() != proxy) { + throw new LuaError("Endpoint methods must be called with ':'"); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationContext.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationContext.java new file mode 100644 index 0000000..3cc594d --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationContext.java @@ -0,0 +1,66 @@ +package dev.propulsionteam.computed.lua.runtime; + +import java.util.ArrayList; +import java.util.List; +import org.luaj.vm2.LuaError; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; +import org.luaj.vm2.lib.VarArgFunction; + +final class LuaInvocationContext { + private final LuaTable table = new LuaTable(); + private PendingLuaInvocation invocation; + + LuaInvocationContext() { + table.set("input", method(args -> invocation.input(args.arg(2).checkjstring()))); + table.set("output", method(args -> { + invocation.output(args.arg(2).checkjstring(), args.arg(3)); + return LuaValue.NIL; + })); + table.set("field", method(args -> invocation.field(args.arg(2).checkjstring()))); + table.set("state", method(args -> invocation.state(args.arg(2).checkjstring()))); + table.set("set_state", method(args -> { + invocation.state(args.arg(2).checkjstring(), args.arg(3)); + return LuaValue.NIL; + })); + table.set("endpoint", method(args -> LuaEndpointProxy.create( + invocation, + args.arg(2).checkjstring(), + args.arg(3).optjstring("")))); + table.set("emit", method(args -> { + List values = new ArrayList<>(Math.max(0, args.narg() - 2)); + for (int index = 3; index <= args.narg(); index++) { + values.add(args.arg(index)); + } + invocation.emit(args.arg(2).checkjstring(), values); + return LuaValue.NIL; + })); + table.set("tick", method(args -> LuaValue.valueOf(invocation.tick()))); + table.set("graph_step", method(args -> LuaValue.valueOf(invocation.graphStep()))); + table.set("is_preview", method(args -> LuaValue.valueOf(invocation.preview()))); + } + + void bind(PendingLuaInvocation invocation) { + this.invocation = invocation; + } + + LuaTable table() { + return table; + } + + private VarArgFunction method(java.util.function.Function action) { + return new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + if (args.arg1() != table) { + throw new LuaError("Context methods must be called with ':'"); + } + if (invocation == null) { + throw new LuaError("Context is not bound to an invocation"); + } + return action.apply(args); + } + }; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationResult.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationResult.java new file mode 100644 index 0000000..b9a135c --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationResult.java @@ -0,0 +1,17 @@ +package dev.propulsionteam.computed.lua.runtime; + +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic; +import java.util.List; +import java.util.Map; +import org.luaj.vm2.LuaValue; + +public record LuaInvocationResult( + LuaNodeStatus status, + Map outputs, + List diagnostics) { + + public LuaInvocationResult { + outputs = outputs == null ? Map.of() : Map.copyOf(outputs); + diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationWorker.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationWorker.java new file mode 100644 index 0000000..9fad046 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaInvocationWorker.java @@ -0,0 +1,45 @@ +package dev.propulsionteam.computed.lua.runtime; + +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.List; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaThread; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +final class LuaInvocationWorker { + private final LuaTable completionMarker = new LuaTable(); + private final LuaThread thread; + + LuaInvocationWorker(LuaSandbox sandbox) { + thread = new LuaThread(sandbox.globals(), sandbox.invocationWorker()); + Varargs primed = thread.resume(completionMarker); + if (!primed.arg1().toboolean() || !suspended()) { + throw new IllegalStateException("Failed to initialize Lua invocation worker"); + } + } + + Varargs invoke(LuaValue callback, LuaTable context, List eventArguments) { + if (eventArguments.isEmpty()) { + return thread.resume(LuaValue.varargsOf(new LuaValue[] {callback, context})); + } + LuaTable packed = new LuaTable(); + packed.set("n", eventArguments.size()); + for (int index = 0; index < eventArguments.size(); index++) { + packed.set(index + 1, eventArguments.get(index)); + } + return thread.resume(LuaValue.varargsOf(new LuaValue[] {callback, context, packed})); + } + + Varargs resume(List arguments) { + return thread.resume(LuaValue.varargsOf(arguments.toArray(LuaValue[]::new))); + } + + boolean completed(Varargs result) { + return result.arg(2) == completionMarker; + } + + boolean suspended() { + return LuaThread.STATUS_NAMES[LuaThread.STATUS_SUSPENDED].equals(thread.getStatus()); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaNodeInstance.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaNodeInstance.java new file mode 100644 index 0000000..1e151e1 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaNodeInstance.java @@ -0,0 +1,241 @@ +package dev.propulsionteam.computed.lua.runtime; + +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic; +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Phase; +import dev.propulsionteam.computed.diagnostics.ComputedDiagnostic.Severity; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.sandbox.LuaInstructionBudget; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.BiConsumer; +import org.luaj.vm2.LuaError; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +public final class LuaNodeInstance { + private final UUID computerId; + private final UUID nodeId; + private final LuaSandbox sandbox; + private final Object endpointHost; + private final LuaNodeDefinition definition; + private final boolean endpointCapable; + private final Map state = new LinkedHashMap<>(); + private final Map outputs = new LinkedHashMap<>(); + private final LuaInvocationContext context = new LuaInvocationContext(); + private LuaInvocationWorker worker; + private PendingLuaInvocation pending; + private ComputedDiagnostic lastDiagnostic; + private LuaNodeStatus status = LuaNodeStatus.IDLE; + + public LuaNodeInstance( + UUID computerId, + UUID nodeId, + LuaSandbox sandbox, + LuaNodeDefinition definition, + Object endpointHost, + boolean endpointCapable) { + this.computerId = Objects.requireNonNull(computerId, "computerId"); + this.nodeId = Objects.requireNonNull(nodeId, "nodeId"); + this.sandbox = Objects.requireNonNull(sandbox, "sandbox"); + this.definition = Objects.requireNonNull(definition, "definition"); + this.endpointHost = endpointHost; + this.endpointCapable = endpointCapable; + worker = endpointCapable ? new LuaInvocationWorker(sandbox) : null; + definition.stateDefaults().forEach((id, value) -> state.put(id, LuaValueCopies.copy(value))); + } + + public LuaInvocationResult run( + Map inputs, + Map fields, + long tick, + long graphStep, + boolean preview, + BiConsumer> eventSink) { + if (pending != null) { + return failure("already_yielded", "Node is already waiting for an endpoint continuation"); + } + if (definition.onRun() == null) { + return failure("missing_on_run", "Node does not declare an on_run callback"); + } + pending = createPending( + definition.onRun(), inputs, fields, tick, graphStep, preview, eventSink); + return advance(() -> pending.start(List.of())); + } + + public LuaInvocationResult event( + String eventName, + List arguments, + Map inputs, + Map fields, + long tick, + long graphStep, + boolean preview, + BiConsumer> eventSink) { + if (pending != null) { + return failure("already_yielded", "Node is already waiting for an endpoint continuation"); + } + LuaValue handler = definition.eventHandlers().get(eventName); + if (handler == null) { + return snapshot(); + } + pending = createPending(handler, inputs, fields, tick, graphStep, preview, eventSink); + return advance(() -> pending.start(arguments == null ? List.of() : arguments)); + } + + public LuaInvocationResult resumeIfReady() { + if (pending == null || !pending.continuationReady()) { + return snapshot(); + } + return advance(pending::resume); + } + + public void cancelYield() { + if (pending != null) { + pending = null; + resetWorker(); + status = LuaNodeStatus.CANCELLED; + lastDiagnostic = diagnostic("yield_cancelled", "Yielded invocation was cancelled"); + } + } + + public Map state() { + return LuaValueCopies.copyMap(state); + } + + public void restoreState(Map restoredState) { + if (pending != null) { + throw new IllegalStateException("Cannot restore state while a node is yielded"); + } + Map checked = LuaValueCopies.copyMap(restoredState); + for (String id : checked.keySet()) { + if (!definition.stateDefaults().containsKey(id)) { + throw new IllegalArgumentException("Unknown state id " + id + " for node " + definition.id()); + } + } + state.clear(); + definition.stateDefaults().forEach((id, value) -> state.put(id, LuaValueCopies.copy(value))); + state.putAll(checked); + } + + public Map outputs() { + return LuaValueCopies.copyMap(outputs); + } + + public LuaNodeStatus status() { + return status; + } + + public LuaNodeDefinition definition() { + return definition; + } + + private PendingLuaInvocation createPending( + LuaValue callback, + Map inputs, + Map fields, + long tick, + long graphStep, + boolean preview, + BiConsumer> eventSink) { + PendingLuaInvocation created = new PendingLuaInvocation( + computerId, + nodeId, + sandbox, + preview, + endpointHost, + tick, + graphStep, + LuaValueCopies.copyMap(inputs), + LuaValueCopies.copyMap(fields), + LuaValueCopies.copyMap(state), + LuaValueCopies.copyMap(outputs), + eventSink == null ? (name, values) -> {} : eventSink, + callback, + worker, + context); + context.bind(created); + return created; + } + + private LuaInvocationResult advance(java.util.function.Supplier action) { + try (LuaInstructionBudget.Scope ignored = sandbox.budget().beginInvocation()) { + Varargs result = action.get(); + if (!result.arg1().toboolean()) { + return failAndDiscard("runtime_error", result.arg(2).tojstring()); + } + if (pending.completed(result)) { + commitPending(); + status = LuaNodeStatus.IDLE; + lastDiagnostic = null; + return snapshot(); + } + if (pending.suspended()) { + if (!pending.waiting()) { + return failAndDiscard("unexpected_yield", "Node yielded without an endpoint continuation"); + } + status = LuaNodeStatus.YIELDED; + lastDiagnostic = null; + return snapshot(); + } + return failAndDiscard("runtime_error", "Lua invocation worker stopped unexpectedly"); + } catch (LuaError error) { + return failAndDiscard("runtime_error", error.getMessage()); + } catch (StackOverflowError error) { + return failAndDiscard("runtime_error", "Lua node exceeded the recursion limit"); + } catch (RuntimeException exception) { + return failAndDiscard("runtime_error", exception.getMessage()); + } + } + + private void commitPending() { + Map checkedState = LuaValueCopies.copyMap(pending.state()); + Map checkedOutputs = LuaValueCopies.copyMap(pending.outputs()); + state.clear(); + state.putAll(checkedState); + outputs.clear(); + outputs.putAll(checkedOutputs); + pending = null; + } + + private LuaInvocationResult failAndDiscard(String code, String message) { + pending = null; + resetWorker(); + status = LuaNodeStatus.FAILED; + lastDiagnostic = diagnostic(code, message); + return snapshot(); + } + + private void resetWorker() { + if (endpointCapable) { + worker = new LuaInvocationWorker(sandbox); + } + } + + private LuaInvocationResult failure(String code, String message) { + lastDiagnostic = diagnostic(code, message); + return snapshot(); + } + + private ComputedDiagnostic diagnostic(String code, String message) { + return new ComputedDiagnostic( + Severity.ERROR, + Phase.RUNTIME, + code, + message, + nodeId, + null, + null); + } + + private LuaInvocationResult snapshot() { + List diagnostics = + lastDiagnostic == null ? List.of() : List.of(lastDiagnostic); + return new LuaInvocationResult(status, outputs, diagnostics); + } + +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaNodeStatus.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaNodeStatus.java new file mode 100644 index 0000000..8cf0335 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaNodeStatus.java @@ -0,0 +1,8 @@ +package dev.propulsionteam.computed.lua.runtime; + +public enum LuaNodeStatus { + IDLE, + YIELDED, + FAILED, + CANCELLED +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaStateCodec.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaStateCodec.java new file mode 100644 index 0000000..f63e5a1 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaStateCodec.java @@ -0,0 +1,165 @@ +package dev.propulsionteam.computed.lua.runtime; + +import java.nio.charset.StandardCharsets; +import java.util.IdentityHashMap; +import java.util.Map; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +public final class LuaStateCodec { + public static final int MAX_DEPTH = 16; + public static final int MAX_BYTES = 4 * 1024 * 1024; + + public CompoundTag encode(LuaValue value) { + Counter counter = new Counter(); + CompoundTag encoded = encode(value == null ? LuaValue.NIL : value, 0, new IdentityHashMap<>(), counter); + if (counter.bytes > MAX_BYTES) { + throw new IllegalArgumentException("Lua state exceeds the four-megabyte program limit"); + } + return encoded; + } + + public LuaValue decode(CompoundTag tag) { + return decodeValue(tag, 0); + } + + private CompoundTag encode( + LuaValue value, + int depth, + Map activeTables, + Counter counter) { + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException("Lua state exceeds the maximum table depth of " + MAX_DEPTH); + } + CompoundTag tag = new CompoundTag(); + if (value.isnil()) { + tag.putString("type", "nil"); + counter.add(4); + return tag; + } + if (value.isboolean()) { + tag.putString("type", "boolean"); + tag.putBoolean("value", value.toboolean()); + counter.add(10); + return tag; + } + if (value.isnumber()) { + double number = value.todouble(); + if (!Double.isFinite(number)) { + throw new IllegalArgumentException("Lua state contains a non-finite number"); + } + tag.putString("type", "number"); + tag.putDouble("value", number); + counter.add(14); + return tag; + } + if (value.isstring()) { + String string = value.tojstring(); + tag.putString("type", "string"); + tag.putString("value", string); + counter.add(8 + string.getBytes(StandardCharsets.UTF_8).length); + return tag; + } + if (!value.istable()) { + throw new IllegalArgumentException("Lua state cannot persist " + value.typename()); + } + LuaTable table = value.checktable(); + if (activeTables.put(table, Boolean.TRUE) != null) { + throw new IllegalArgumentException("Lua state contains a cyclic table"); + } + tag.putString("type", "table"); + ListTag entries = new ListTag(); + LuaValue key = LuaValue.NIL; + while (true) { + Varargs next = table.next(key); + key = next.arg1(); + if (key.isnil()) { + break; + } + LuaValue entryValue = next.arg(2); + CompoundTag entry = new CompoundTag(); + entry.put("key", encodeKey(key, counter)); + entry.put("value", encode(entryValue, depth + 1, activeTables, counter)); + entries.add(entry); + } + activeTables.remove(table); + tag.put("entries", entries); + counter.add(8); + return tag; + } + + private CompoundTag encodeKey(LuaValue key, Counter counter) { + CompoundTag tag = new CompoundTag(); + if (key.isnumber()) { + double number = key.todouble(); + long integer = (long) number; + if (!Double.isFinite(number) || number != integer) { + throw new IllegalArgumentException("Lua table keys must be strings or integers"); + } + tag.putString("type", "integer"); + tag.putLong("value", integer); + counter.add(16); + return tag; + } + if (key.isstring()) { + String string = key.tojstring(); + tag.putString("type", "string"); + tag.putString("value", string); + counter.add(8 + string.getBytes(StandardCharsets.UTF_8).length); + return tag; + } + throw new IllegalArgumentException("Lua table keys must be strings or integers"); + } + + private LuaValue decodeValue(CompoundTag tag, int depth) { + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException("Encoded Lua state exceeds the maximum table depth"); + } + return switch (tag.getString("type")) { + case "nil" -> LuaValue.NIL; + case "boolean" -> LuaValue.valueOf(tag.getBoolean("value")); + case "number" -> decodeNumber(tag); + case "string" -> LuaValue.valueOf(tag.getString("value")); + case "table" -> decodeTable(tag, depth); + default -> throw new IllegalArgumentException("Unknown encoded Lua value type: " + tag.getString("type")); + }; + } + + private LuaValue decodeNumber(CompoundTag tag) { + double number = tag.getDouble("value"); + if (!Double.isFinite(number)) { + throw new IllegalArgumentException("Encoded Lua state contains a non-finite number"); + } + return LuaValue.valueOf(number); + } + + private LuaTable decodeTable(CompoundTag tag, int depth) { + LuaTable table = new LuaTable(); + ListTag entries = tag.getList("entries", Tag.TAG_COMPOUND); + for (int index = 0; index < entries.size(); index++) { + CompoundTag entry = entries.getCompound(index); + table.set(decodeKey(entry.getCompound("key")), decodeValue(entry.getCompound("value"), depth + 1)); + } + return table; + } + + private LuaValue decodeKey(CompoundTag tag) { + return switch (tag.getString("type")) { + case "string" -> LuaValue.valueOf(tag.getString("value")); + case "integer" -> LuaValue.valueOf(tag.getLong("value")); + default -> throw new IllegalArgumentException("Unknown encoded Lua table key type"); + }; + } + + private static final class Counter { + private int bytes; + + private void add(int amount) { + bytes = Math.addExact(bytes, amount); + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaValueCopies.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaValueCopies.java new file mode 100644 index 0000000..e25f55c --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaValueCopies.java @@ -0,0 +1,159 @@ +package dev.propulsionteam.computed.lua.runtime; + +import java.nio.charset.StandardCharsets; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +public final class LuaValueCopies { + private LuaValueCopies() {} + + public static LuaValue copy(LuaValue value) { + return new Copier().copy(value == null ? LuaValue.NIL : value, 0); + } + + public static Map copyMap(Map source) { + Map copied = new LinkedHashMap<>(); + if (source == null || source.isEmpty()) { + return copied; + } + Copier copier = new Copier(); + source.forEach((id, value) -> copied.put(id, copier.copy(value, 0))); + return copied; + } + + public static boolean equivalent(Map left, Map right) { + if (left == null || right == null || left.size() != right.size()) { + return false; + } + for (Map.Entry entry : right.entrySet()) { + LuaValue previous = left.get(entry.getKey()); + if (previous == null || !equivalent(previous, entry.getValue(), 0)) { + return false; + } + } + return true; + } + + private static boolean equivalent(LuaValue left, LuaValue right, int depth) { + if (left == right) { + return true; + } + if (left == null || right == null || left.type() != right.type() || depth > LuaStateCodec.MAX_DEPTH) { + return false; + } + if (left.isnil()) { + return true; + } + if (left.isboolean()) { + return left.toboolean() == right.toboolean(); + } + if (left.isnumber()) { + return Double.doubleToLongBits(left.todouble()) == Double.doubleToLongBits(right.todouble()); + } + if (left.isstring()) { + return left.raweq(right); + } + if (!left.istable()) { + return false; + } + LuaTable leftTable = left.checktable(); + LuaTable rightTable = right.checktable(); + if (leftTable.keyCount() != rightTable.keyCount()) { + return false; + } + LuaValue key = LuaValue.NIL; + while (true) { + Varargs next = leftTable.next(key); + key = next.arg1(); + if (key.isnil()) { + return true; + } + LuaValue rightValue = rightTable.get(key); + if (rightValue.isnil() && !next.arg(2).isnil()) { + return false; + } + if (!equivalent(next.arg(2), rightValue, depth + 1)) { + return false; + } + } + } + + private static final class Copier { + private final Map activeTables = new IdentityHashMap<>(); + private int bytes; + + private LuaValue copy(LuaValue value, int depth) { + if (depth > LuaStateCodec.MAX_DEPTH) { + throw new IllegalArgumentException( + "Lua state exceeds the maximum table depth of " + LuaStateCodec.MAX_DEPTH); + } + if (value.isnil()) { + addBytes(4); + return LuaValue.NIL; + } + if (value.isboolean()) { + addBytes(10); + return value; + } + if (value.isnumber()) { + if (!Double.isFinite(value.todouble())) { + throw new IllegalArgumentException("Lua state contains a non-finite number"); + } + addBytes(14); + return value; + } + if (value.isstring()) { + addBytes(8 + value.tojstring().getBytes(StandardCharsets.UTF_8).length); + return value; + } + if (!value.istable()) { + throw new IllegalArgumentException("Lua state cannot persist " + value.typename()); + } + LuaTable source = value.checktable(); + if (activeTables.put(source, Boolean.TRUE) != null) { + throw new IllegalArgumentException("Lua state contains a cyclic table"); + } + LuaTable target = new LuaTable(); + LuaValue key = LuaValue.NIL; + while (true) { + Varargs next = source.next(key); + key = next.arg1(); + if (key.isnil()) { + break; + } + target.set(copyKey(key), copy(next.arg(2), depth + 1)); + } + activeTables.remove(source); + addBytes(8); + return target; + } + + private LuaValue copyKey(LuaValue key) { + if (key.isnumber()) { + double number = key.todouble(); + long integer = (long) number; + if (!Double.isFinite(number) || number != integer) { + throw new IllegalArgumentException("Lua table keys must be strings or integers"); + } + addBytes(16); + return key; + } + if (key.isstring()) { + addBytes(8 + key.tojstring().getBytes(StandardCharsets.UTF_8).length); + return key; + } + throw new IllegalArgumentException("Lua table keys must be strings or integers"); + } + + private void addBytes(int amount) { + bytes = Math.addExact(bytes, amount); + if (bytes > LuaStateCodec.MAX_BYTES) { + throw new IllegalArgumentException("Lua state exceeds the four-megabyte program limit"); + } + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaValueValidator.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaValueValidator.java new file mode 100644 index 0000000..6d1d062 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/LuaValueValidator.java @@ -0,0 +1,19 @@ +package dev.propulsionteam.computed.lua.runtime; + +import dev.propulsionteam.computed.lua.endpoint.EndpointType; +import org.luaj.vm2.LuaValue; + +final class LuaValueValidator { + private LuaValueValidator() {} + + static boolean matches(EndpointType type, LuaValue value) { + return switch (type) { + case ANY -> true; + case NIL -> value.isnil(); + case NUMBER -> value.isnumber(); + case BOOLEAN -> value.isboolean(); + case STRING -> value.isstring(); + case TABLE -> value.istable(); + }; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/runtime/PendingLuaInvocation.java b/src/main/java/dev/propulsionteam/computed/lua/runtime/PendingLuaInvocation.java new file mode 100644 index 0000000..535d08e --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/runtime/PendingLuaInvocation.java @@ -0,0 +1,167 @@ +package dev.propulsionteam.computed.lua.runtime; + +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.BiConsumer; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +final class PendingLuaInvocation { + private final UUID computerId; + private final UUID nodeId; + private final LuaSandbox sandbox; + private final boolean preview; + private final Object endpointHost; + private final long tick; + private final long graphStep; + private final Map inputs; + private final Map fields; + private final Map state; + private final Map outputs; + private final BiConsumer> eventSink; + private final LuaValue callback; + private final LuaInvocationWorker worker; + private final LuaInvocationContext context; + private CompletionStage continuation; + + PendingLuaInvocation( + UUID computerId, + UUID nodeId, + LuaSandbox sandbox, + boolean preview, + Object endpointHost, + long tick, + long graphStep, + Map inputs, + Map fields, + Map state, + Map outputs, + BiConsumer> eventSink, + LuaValue callback, + LuaInvocationWorker worker, + LuaInvocationContext context) { + this.computerId = computerId; + this.nodeId = nodeId; + this.sandbox = sandbox; + this.preview = preview; + this.endpointHost = endpointHost; + this.tick = tick; + this.graphStep = graphStep; + this.inputs = inputs; + this.fields = fields; + this.state = state; + this.outputs = outputs; + this.eventSink = eventSink; + this.callback = callback; + this.worker = worker; + this.context = context; + } + + Varargs start(List eventArguments) { + if (worker == null) { + LuaValue[] arguments = new LuaValue[eventArguments.size() + 1]; + arguments[0] = context.table(); + for (int index = 0; index < eventArguments.size(); index++) { + arguments[index + 1] = eventArguments.get(index); + } + callback.invoke(LuaValue.varargsOf(arguments)); + return LuaValue.TRUE; + } + return worker.invoke(callback, context.table(), eventArguments); + } + + Varargs resume() { + CompletableFuture future = continuation.toCompletableFuture(); + EndpointResult.Immediate result = future.join(); + continuation = null; + return worker.resume(result.values()); + } + + void yieldFor(CompletionStage continuation) { + if (this.continuation != null) { + throw new IllegalStateException("A Lua node cannot wait for multiple endpoint calls"); + } + this.continuation = continuation; + } + + boolean continuationReady() { + return continuation != null && continuation.toCompletableFuture().isDone(); + } + + boolean waiting() { + return continuation != null; + } + + boolean suspended() { + return worker != null && worker.suspended(); + } + + boolean completed(Varargs result) { + return worker == null || worker.completed(result); + } + + Map state() { + return state; + } + + Map outputs() { + return outputs; + } + + UUID computerId() { + return computerId; + } + + UUID nodeId() { + return nodeId; + } + + LuaSandbox sandbox() { + return sandbox; + } + + boolean preview() { + return preview; + } + + Object endpointHost() { + return endpointHost; + } + + LuaValue input(String id) { + return inputs.getOrDefault(id, LuaValue.NIL); + } + + void output(String id, LuaValue value) { + outputs.put(id, value); + } + + LuaValue field(String id) { + return fields.getOrDefault(id, LuaValue.NIL); + } + + LuaValue state(String id) { + return state.getOrDefault(id, LuaValue.NIL); + } + + void state(String id, LuaValue value) { + state.put(id, value); + } + + void emit(String name, List values) { + eventSink.accept(name, values); + } + + long tick() { + return tick; + } + + long graphStep() { + return graphStep; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaBudgetDebugLib.java b/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaBudgetDebugLib.java new file mode 100644 index 0000000..e53565f --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaBudgetDebugLib.java @@ -0,0 +1,34 @@ +package dev.propulsionteam.computed.lua.sandbox; + +import org.luaj.vm2.LuaClosure; +import org.luaj.vm2.LuaFunction; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; +import org.luaj.vm2.lib.DebugLib; + +final class LuaBudgetDebugLib extends DebugLib { + private final LuaInstructionBudget budget; + private int instructions; + + LuaBudgetDebugLib(LuaInstructionBudget budget) { + this.budget = budget; + } + + @Override + public void onCall(LuaFunction function) {} + + @Override + public void onCall(LuaClosure closure, Varargs varargs, LuaValue[] stack) {} + + @Override + public void onInstruction(int pc, Varargs varargs, int top) { + instructions++; + if (instructions == LuaInstructionBudget.METERING_QUANTUM) { + instructions = 0; + budget.consume(LuaInstructionBudget.METERING_QUANTUM); + } + } + + @Override + public void onReturn() {} +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaInstructionBudget.java b/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaInstructionBudget.java new file mode 100644 index 0000000..ed3674d --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaInstructionBudget.java @@ -0,0 +1,85 @@ +package dev.propulsionteam.computed.lua.sandbox; + +public final class LuaInstructionBudget { + public static final int DEFAULT_INVOCATION_LIMIT = 50_000; + public static final int DEFAULT_TICK_LIMIT = 500_000; + public static final int METERING_QUANTUM = 64; + + private final int invocationLimit; + private final int tickLimit; + private volatile int tickRemaining; + private volatile int invocationRemaining; + private volatile boolean active; + + public LuaInstructionBudget() { + this(DEFAULT_INVOCATION_LIMIT, DEFAULT_TICK_LIMIT); + } + + public LuaInstructionBudget(int invocationLimit, int tickLimit) { + if (invocationLimit < 1 || tickLimit < invocationLimit) { + throw new IllegalArgumentException("Invalid Lua instruction limits"); + } + this.invocationLimit = invocationLimit; + this.tickLimit = tickLimit; + tickRemaining = tickLimit; + } + + public void beginTick() { + tickRemaining = tickLimit; + invocationRemaining = 0; + active = false; + } + + public Scope beginInvocation() { + if (active) { + throw new IllegalStateException("A Lua invocation is already being metered"); + } + active = true; + invocationRemaining = invocationLimit; + try { + consume(METERING_QUANTUM); + } catch (RuntimeException exception) { + active = false; + invocationRemaining = 0; + throw exception; + } + return new Scope(this); + } + + void consume(int amount) { + if (!active) { + return; + } + invocationRemaining -= amount; + tickRemaining -= amount; + if (invocationRemaining < 0) { + throw new LuaInstructionLimitException( + "Lua node exceeded the " + invocationLimit + "-instruction invocation limit"); + } + if (tickRemaining < 0) { + throw new LuaInstructionLimitException( + "Computer exceeded the " + tickLimit + "-instruction tick limit"); + } + } + + public int tickRemaining() { + return tickRemaining; + } + + public final class Scope implements AutoCloseable { + private LuaInstructionBudget owner; + + private Scope(LuaInstructionBudget owner) { + this.owner = owner; + } + + @Override + public void close() { + if (owner != null) { + owner.active = false; + owner.invocationRemaining = 0; + owner = null; + } + } + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaInstructionLimitException.java b/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaInstructionLimitException.java new file mode 100644 index 0000000..b724f4e --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaInstructionLimitException.java @@ -0,0 +1,9 @@ +package dev.propulsionteam.computed.lua.sandbox; + +import org.luaj.vm2.LuaError; + +public final class LuaInstructionLimitException extends LuaError { + public LuaInstructionLimitException(String message) { + super(message); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaSandbox.java b/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaSandbox.java new file mode 100644 index 0000000..288f229 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/lua/sandbox/LuaSandbox.java @@ -0,0 +1,96 @@ +package dev.propulsionteam.computed.lua.sandbox; + +import java.util.List; +import java.util.Objects; +import org.luaj.vm2.Globals; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaThread; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.compiler.LuaC; +import org.luaj.vm2.lib.BaseLib; +import org.luaj.vm2.lib.Bit32Lib; +import org.luaj.vm2.lib.CoroutineLib; +import org.luaj.vm2.lib.MathLib; +import org.luaj.vm2.lib.PackageLib; +import org.luaj.vm2.lib.StringLib; +import org.luaj.vm2.lib.TableLib; + +public final class LuaSandbox { + private static final List BLOCKED_GLOBALS = List.of( + "io", + "os", + "debug", + "package", + "require", + "load", + "loadfile", + "dofile", + "luajava"); + + private final Globals globals; + private final LuaInstructionBudget budget; + private final LuaValue invocationWorker; + + public LuaSandbox() { + this(new LuaInstructionBudget()); + } + + public LuaSandbox(LuaInstructionBudget budget) { + this.budget = Objects.requireNonNull(budget, "budget"); + globals = new Globals(); + globals.load(new BaseLib()); + globals.load(new PackageLib()); + globals.load(new MathLib()); + globals.load(new StringLib()); + globals.load(new TableLib()); + globals.load(new Bit32Lib()); + globals.load(new CoroutineLib()); + LuaC.install(globals); + globals.load(new LuaBudgetDebugLib(budget)); + invocationWorker = globals.load(""" + local unpack_values = table.unpack or unpack + return function(completion_marker) + local callback, context, arguments = coroutine.yield() + while true do + if arguments then + callback(context, unpack_values(arguments, 1, arguments.n)) + else + callback(context) + end + callback, context, arguments = coroutine.yield(completion_marker) + end + end + """, "@computed-invocation-worker").call(); + BLOCKED_GLOBALS.forEach(name -> globals.set(name, LuaValue.NIL)); + } + + public Globals createEnvironment() { + Globals environment = new Globals(); + environment.debuglib = globals.debuglib; + LuaTable metatable = new LuaTable(); + metatable.set("__index", globals); + environment.setmetatable(metatable); + environment.set("_G", environment); + return environment; + } + + public Globals globals() { + return globals; + } + + public LuaInstructionBudget budget() { + return budget; + } + + public LuaValue invocationWorker() { + return invocationWorker; + } + + public boolean isBlocked(String name) { + return globals.get(name).isnil(); + } + + public void installHook(LuaThread thread) { + Objects.requireNonNull(thread, "thread"); + } +} diff --git a/src/main/java/dev/propulsionteam/computed/network/ComputedNetworking.java b/src/main/java/dev/propulsionteam/computed/network/ComputedNetworking.java index 4f2d248..ad64a0e 100644 --- a/src/main/java/dev/propulsionteam/computed/network/ComputedNetworking.java +++ b/src/main/java/dev/propulsionteam/computed/network/ComputedNetworking.java @@ -1,16 +1,11 @@ package dev.propulsionteam.computed.network; -import dev.propulsionteam.computed.internal.node.api.FunctionCardNode; -import dev.propulsionteam.computed.internal.node.api.WGraph; -import dev.propulsionteam.computed.internal.node.api.WNode; import dev.propulsionteam.computed.ComputerEditorBridge; import dev.propulsionteam.computed.Computed; import dev.propulsionteam.computed.content.blocks.ComputerBlockEntity; -import dev.propulsionteam.computed.customnodes.ComputedCustomNodes; import dev.propulsionteam.computed.content.monitors.MonitorBlockEntity; import dev.propulsionteam.computed.content.monitors.widgets.SliderWidget; import dev.propulsionteam.computed.content.monitors.widgets.Widget; -import dev.propulsionteam.computed.content.nodes.widgets.InteractiveWidgetNode; import net.minecraft.core.BlockPos; import net.minecraft.nbt.CompoundTag; import net.minecraft.server.level.ServerPlayer; @@ -18,7 +13,6 @@ import net.minecraft.world.phys.Vec3; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.common.NeoForge; -import net.neoforged.neoforge.event.entity.player.PlayerEvent; import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import net.neoforged.neoforge.network.handling.IPayloadContext; @@ -27,7 +21,6 @@ import java.util.UUID; public final class ComputedNetworking { - private static final double MAX_EDIT_DISTANCE_SQ = 16.0 * 16.0; /** Pixels per monitor block; widget x/y/w/h are in this coordinate system. */ public static final int SCREEN_PX_PER_BLOCK = 64; /** Monitor model/texture pixels reserved by the bezel on each outer screen edge. */ @@ -42,14 +35,6 @@ private ComputedNetworking() {} public static void register(IEventBus modBus) { modBus.addListener(ComputedNetworking::registerPayloads); - NeoForge.EVENT_BUS.addListener(ComputedNetworking::onPlayerLogin); - } - - /** Push the server's data-driven node definitions to a joining player so their editor and graphs match the server. */ - private static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { - if (event.getEntity() instanceof ServerPlayer player) { - PacketDistributor.sendToPlayer(player, new SyncCustomNodesPayload(ComputedCustomNodes.readRawDefinitions())); - } } private static void registerPayloads(RegisterPayloadHandlersEvent event) { @@ -70,14 +55,6 @@ private static void registerPayloads(RegisterPayloadHandlersEvent event) { MonitorClickPayload.TYPE, MonitorClickPayload.STREAM_CODEC, ComputedNetworking::handleMonitorClick); - registrar.playToClient( - SyncCustomNodesPayload.TYPE, - SyncCustomNodesPayload.STREAM_CODEC, - ComputedNetworking::handleSyncCustomNodes); - } - - private static void handleSyncCustomNodes(SyncCustomNodesPayload payload, IPayloadContext ctx) { - ctx.enqueueWork(() -> ComputedCustomNodes.applyServerDefinitions(payload.definitions())); } public static OpenComputerEditorPayload openPayload(BlockPos pos, long serverRevision, CompoundTag graphTag) { @@ -103,18 +80,18 @@ private static void handleSaveGraph(SaveComputerGraphPayload payload, IPayloadCo return; } BlockPos pos = payload.pos(); - if (player.distanceToSqr(Vec3.atCenterOf(pos)) > MAX_EDIT_DISTANCE_SQ) { + double distanceSquared = player.distanceToSqr(Vec3.atCenterOf(pos)); + String accessError = ComputerEditPolicy.access( + distanceSquared, + player.mayBuild(), + player.level().mayInteract(player, pos)); + if (accessError != null) { Computed.LOGGER.debug( - "Rejected graph save from {} at {} (distance {:.2f} > {:.2f})", + "Rejected graph save from {} at {} ({})", player.getGameProfile().getName(), pos, - Math.sqrt(player.distanceToSqr(Vec3.atCenterOf(pos))), - Math.sqrt(MAX_EDIT_DISTANCE_SQ)); - rejectGraphSave(player, payload, -1L, "computer is too far away"); - return; - } - if (!player.mayBuild() || !player.level().mayInteract(player, pos)) { - rejectGraphSave(player, payload, -1L, "you do not have permission to edit this computer"); + accessError); + rejectGraphSave(player, payload, -1L, accessError); return; } BlockEntity be = player.level().getBlockEntity(pos); @@ -161,7 +138,7 @@ private static void handleMonitorClick(MonitorClickPayload payload, IPayloadCont ctx.enqueueWork(() -> { if (!(ctx.player() instanceof ServerPlayer player)) return; BlockPos originPos = payload.originPos(); - if (player.distanceToSqr(Vec3.atCenterOf(originPos)) > MAX_EDIT_DISTANCE_SQ) return; + if (player.distanceToSqr(Vec3.atCenterOf(originPos)) > ComputerEditPolicy.MAX_DISTANCE_SQ) return; BlockEntity be = player.level().getBlockEntity(originPos); if (!(be instanceof MonitorBlockEntity origin)) return; BlockPos ownerPos = origin.getOwnerComputerPos(); @@ -176,26 +153,12 @@ private static void handleMonitorClick(MonitorClickPayload payload, IPayloadCont for (Widget w : origin.getDrawList().widgets()) { if (px < w.x() || px >= w.x() + w.w() || py < w.y() || py >= w.y() + w.h()) continue; - WNode node = findNodeById(computer.getGraph(), w.id()); - if (!(node instanceof InteractiveWidgetNode interactive)) continue; double value = 1.0; if (w instanceof SliderWidget) { value = w.w() <= 0 ? 0.0 : (double) (px - w.x()) / (double) w.w(); } - interactive.onWidgetInput(value); - return; + if (computer.handleWidgetInput(w.id(), value)) return; } }); } - - private static WNode findNodeById(WGraph g, UUID id) { - for (WNode n : g.getNodes()) { - if (n.getId().equals(id)) return n; - if (n instanceof FunctionCardNode fc) { - WNode hit = findNodeById(fc.getInnerGraph(), id); - if (hit != null) return hit; - } - } - return null; - } } diff --git a/src/main/java/dev/propulsionteam/computed/network/ComputerEditPolicy.java b/src/main/java/dev/propulsionteam/computed/network/ComputerEditPolicy.java new file mode 100644 index 0000000..c60fc75 --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/network/ComputerEditPolicy.java @@ -0,0 +1,119 @@ +package dev.propulsionteam.computed.network; + +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.node.BundledLuaLibrary; +import dev.propulsionteam.computed.lua.node.IntegrationLuaLibrary; +import dev.propulsionteam.computed.lua.node.LuaDefinitionLoader; +import dev.propulsionteam.computed.lua.node.LuaFieldValues; +import dev.propulsionteam.computed.lua.node.LuaNodeDefinition; +import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public final class ComputerEditPolicy { + public static final double MAX_DISTANCE_SQ = 16.0 * 16.0; + public static final int MAX_NODES = 4096; + public static final int MAX_CONNECTIONS = 20_000; + public static final int MAX_PROGRAM_BYTES = 4 * 1024 * 1024; + + private ComputerEditPolicy() {} + + public static String access(double distanceSquared, boolean mayBuild, boolean mayInteract) { + if (!Double.isFinite(distanceSquared) || distanceSquared > MAX_DISTANCE_SQ) { + return "computer is too far away"; + } + if (!mayBuild || !mayInteract) { + return "you do not have permission to edit this computer"; + } + return null; + } + + public static String revision(long authoritative, long expected) { + return authoritative == expected + ? null + : "stale editor revision (expected " + authoritative + ", received " + expected + ")"; + } + + public static String encodedSize(int bytes) { + if (bytes < 0) { + return "program NBT could not be measured safely"; + } + return bytes > MAX_PROGRAM_BYTES + ? "program exceeds the encoded size limit of " + MAX_PROGRAM_BYTES + " bytes" + : null; + } + + public static String programShape(ComputedProgramV3 candidate) { + if (candidate.rootGraph().nodes().size() > MAX_NODES) { + return "program exceeds the node limit of " + MAX_NODES; + } + if (candidate.rootGraph().connections().size() > MAX_CONNECTIONS) { + return "program exceeds the connection limit of " + MAX_CONNECTIONS; + } + if (candidate.library().size() > ComputedProgramV3.MAX_EMBEDDED_DEFINITIONS) { + return "program exceeds the embedded definition limit of " + + ComputedProgramV3.MAX_EMBEDDED_DEFINITIONS; + } + return fieldValues(candidate); + } + + private static String fieldValues(ComputedProgramV3 candidate) { + Map sources = new LinkedHashMap<>(BundledLuaLibrary.load()); + sources.putAll(IntegrationLuaLibrary.load()); + sources.putAll(candidate.library()); + Map definitions = new LinkedHashMap<>(); + Set attemptedDefinitions = new LinkedHashSet<>(); + LuaSourceCompiler compiler = new LuaSourceCompiler(); + LuaDefinitionLoader loader = new LuaDefinitionLoader(); + LuaSandbox sandbox = new LuaSandbox(); + for (var node : candidate.rootGraph().nodes()) { + String definitionId = node.definitionId(); + LuaDefinitionSource source = sources.get(definitionId); + if (source == null || !attemptedDefinitions.add(definitionId)) { + continue; + } + try { + definitions.put( + definitionId, + loader.load(compiler.compile(source.apiVersion(), source.source()), sandbox)); + } catch (RuntimeException ignored) { + } + } + LuaStateCodec codec = new LuaStateCodec(); + for (var node : candidate.rootGraph().nodes()) { + LuaNodeDefinition definition = definitions.get(node.definitionId()); + if (definition == null) { + continue; + } + Map schemas = + new LinkedHashMap<>(); + definition.fields().forEach(field -> schemas.put(field.id(), field)); + for (String id : node.fields().keySet()) { + if (!schemas.containsKey(id)) { + return "node " + node.id() + " contains undeclared field " + id; + } + } + for (var field : definition.fields()) { + var encoded = node.fields().get(field.id()); + if (encoded == null) { + continue; + } + try { + String error = LuaFieldValues.validationError(field, codec.decode(encoded)); + if (error != null) { + return "node " + node.id() + ' ' + error; + } + } catch (RuntimeException exception) { + return "node " + node.id() + " field " + field.id() + + " could not be decoded: " + exception.getMessage(); + } + } + } + return null; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/network/SyncCustomNodesPayload.java b/src/main/java/dev/propulsionteam/computed/network/SyncCustomNodesPayload.java deleted file mode 100644 index e92eca8..0000000 --- a/src/main/java/dev/propulsionteam/computed/network/SyncCustomNodesPayload.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.propulsionteam.computed.network; - -import dev.propulsionteam.computed.Computed; -import java.util.List; -import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.codec.ByteBufCodecs; -import net.minecraft.network.codec.StreamCodec; -import net.minecraft.network.protocol.common.custom.CustomPacketPayload; -import net.minecraft.resources.ResourceLocation; - -/** Server→client push of the server's data-driven node definitions (raw JSON), making the server authoritative. */ -public record SyncCustomNodesPayload(List definitions) implements CustomPacketPayload { - /** Per-definition JSON cap; generous so large nodes fit but a single payload can't be unbounded. */ - private static final int MAX_DEFINITION_CHARS = 1 << 20; - - public static final CustomPacketPayload.Type TYPE = - new CustomPacketPayload.Type<>(ResourceLocation.fromNamespaceAndPath(Computed.MODID, "sync_custom_nodes")); - - public static final StreamCodec STREAM_CODEC = StreamCodec.composite( - ByteBufCodecs.stringUtf8(MAX_DEFINITION_CHARS).apply(ByteBufCodecs.list()), - SyncCustomNodesPayload::definitions, - SyncCustomNodesPayload::new); - - @Override - public Type type() { - return TYPE; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/ComputedProgram.java b/src/main/java/dev/propulsionteam/computed/node/program/ComputedProgram.java deleted file mode 100644 index 617fc07..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/ComputedProgram.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.List; -import java.util.Objects; -import net.minecraft.nbt.CompoundTag; - -/** Versioned root object containing the executable graph and its function library. */ -public record ComputedProgram( - long revision, - GraphModel rootGraph, - List functions, - List diagnostics, - CompoundTag metadata) { - - public static final int FORMAT_VERSION = 2; - - public ComputedProgram { - revision = Math.max(0L, revision); - Objects.requireNonNull(rootGraph, "rootGraph"); - functions = functions == null ? List.of() : List.copyOf(functions); - diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); - metadata = metadata == null ? new CompoundTag() : metadata.copy(); - } - - public ComputedProgram(GraphModel rootGraph, List functions) { - this(0L, rootGraph, functions, List.of(), new CompoundTag()); - } - - @Override - public CompoundTag metadata() { - return metadata.copy(); - } - - public ComputedProgram withDiagnostics(List newDiagnostics) { - return new ComputedProgram(revision, rootGraph, functions, newDiagnostics, metadata); - } - - public ComputedProgram withRevision(long newRevision) { - return new ComputedProgram(newRevision, rootGraph, functions, diagnostics, metadata); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/ConnectionModel.java b/src/main/java/dev/propulsionteam/computed/node/program/ConnectionModel.java deleted file mode 100644 index e22ae61..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/ConnectionModel.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.List; -import java.util.Objects; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; - -/** A directed connection between two stable port ids. */ -public record ConnectionModel( - UUID id, - UUID sourceNode, - PortId sourcePort, - UUID targetNode, - PortId targetPort, - List waypoints, - CompoundTag rawTag) { - - public ConnectionModel { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(sourceNode, "sourceNode"); - Objects.requireNonNull(sourcePort, "sourcePort"); - Objects.requireNonNull(targetNode, "targetNode"); - Objects.requireNonNull(targetPort, "targetPort"); - waypoints = waypoints == null ? List.of() : List.copyOf(waypoints); - rawTag = rawTag == null ? new CompoundTag() : rawTag.copy(); - } - - @Override - public CompoundTag rawTag() { - return rawTag.copy(); - } - - public record Waypoint(double x, double y) {} -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/FunctionModel.java b/src/main/java/dev/propulsionteam/computed/node/program/FunctionModel.java deleted file mode 100644 index 92396cf..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/FunctionModel.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.Objects; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; - -/** Named reusable function graph. */ -public record FunctionModel( - UUID id, - String name, - GraphModel graph, - CompoundTag metadata, - CompoundTag rawTag) { - - public FunctionModel { - Objects.requireNonNull(id, "id"); - name = name == null ? "" : name; - Objects.requireNonNull(graph, "graph"); - metadata = metadata == null ? new CompoundTag() : metadata.copy(); - rawTag = rawTag == null ? new CompoundTag() : rawTag.copy(); - } - - public FunctionModel(UUID id, String name, GraphModel graph) { - this(id, name, graph, new CompoundTag(), new CompoundTag()); - } - - @Override - public CompoundTag metadata() { - return metadata.copy(); - } - - @Override - public CompoundTag rawTag() { - return rawTag.copy(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/GraphModel.java b/src/main/java/dev/propulsionteam/computed/node/program/GraphModel.java deleted file mode 100644 index bf0bc68..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/GraphModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; - -/** Immutable persistent graph model. Invalid or dangling data is retained for diagnostics and repair. */ -public record GraphModel( - UUID id, - List nodes, - List connections, - List sections, - CompoundTag metadata, - CompoundTag rawTag) { - - public GraphModel { - Objects.requireNonNull(id, "id"); - nodes = nodes == null ? List.of() : List.copyOf(nodes); - connections = connections == null ? List.of() : List.copyOf(connections); - sections = sections == null ? List.of() : List.copyOf(sections); - metadata = metadata == null ? new CompoundTag() : metadata.copy(); - rawTag = rawTag == null ? new CompoundTag() : rawTag.copy(); - } - - public GraphModel(UUID id, List nodes, List connections, List sections) { - this(id, nodes, connections, sections, new CompoundTag(), new CompoundTag()); - } - - @Override - public CompoundTag metadata() { - return metadata.copy(); - } - - @Override - public CompoundTag rawTag() { - return rawTag.copy(); - } - - public Optional node(UUID nodeId) { - return nodes.stream().filter(node -> node.id().equals(nodeId)).findFirst(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/NodeModel.java b/src/main/java/dev/propulsionteam/computed/node/program/NodeModel.java deleted file mode 100644 index 9f165b5..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/NodeModel.java +++ /dev/null @@ -1,68 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; - -/** - * Persistent node instance. {@code rawTag} is deliberately retained so a missing addon node can be - * loaded, moved, reconnected, saved, and later recovered without understanding its private data. - */ -public record NodeModel( - UUID id, - String typeId, - String originalTypeId, - String title, - int x, - int y, - CompoundTag properties, - CompoundTag state, - List ports, - PlaceholderStatus placeholderStatus, - CompoundTag rawTag) { - - public NodeModel { - Objects.requireNonNull(id, "id"); - typeId = typeId == null || typeId.isBlank() ? "computed:missing" : typeId; - originalTypeId = originalTypeId == null ? typeId : originalTypeId; - title = title == null ? "" : title; - properties = properties == null ? new CompoundTag() : properties.copy(); - state = state == null ? new CompoundTag() : state.copy(); - ports = ports == null ? List.of() : List.copyOf(ports); - placeholderStatus = placeholderStatus == null ? PlaceholderStatus.RESOLVED : placeholderStatus; - rawTag = rawTag == null ? new CompoundTag() : rawTag.copy(); - } - - @Override - public CompoundTag properties() { - return properties.copy(); - } - - @Override - public CompoundTag state() { - return state.copy(); - } - - @Override - public CompoundTag rawTag() { - return rawTag.copy(); - } - - public boolean isPlaceholder() { - return placeholderStatus != PlaceholderStatus.RESOLVED; - } - - public Optional port(PortId portId, PortModel.Direction direction) { - return ports.stream() - .filter(port -> port.direction() == direction && port.id().equals(portId)) - .findFirst(); - } - - public enum PlaceholderStatus { - RESOLVED, - MISSING_TYPE, - MALFORMED_TYPE - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/PortId.java b/src/main/java/dev/propulsionteam/computed/node/program/PortId.java deleted file mode 100644 index 0743b68..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/PortId.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.Objects; - -/** - * Stable, schema-owned identifier for a node port. Unlike the legacy format, a port id is not an - * index into the node's current input or output list. - */ -public record PortId(String value) implements Comparable { - public PortId { - Objects.requireNonNull(value, "value"); - if (value.isBlank()) { - throw new IllegalArgumentException("Port id must not be blank"); - } - if (value.length() > 256) { - throw new IllegalArgumentException("Port id is longer than 256 characters"); - } - } - - public static PortId legacyInput(int index) { - return new PortId("legacy.input." + index); - } - - public static PortId legacyOutput(int index) { - return new PortId("legacy.output." + index); - } - - @Override - public int compareTo(PortId other) { - return value.compareTo(other.value); - } - - @Override - public String toString() { - return value; - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/PortModel.java b/src/main/java/dev/propulsionteam/computed/node/program/PortModel.java deleted file mode 100644 index a0a8a3b..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/PortModel.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.Objects; -import net.minecraft.nbt.CompoundTag; - -/** Persisted description and raw value data for one node port. */ -public record PortModel( - PortId id, - Direction direction, - String valueType, - String label, - CompoundTag data) { - - public static final String UNKNOWN_VALUE_TYPE = "unknown"; - - public PortModel { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(direction, "direction"); - valueType = valueType == null || valueType.isBlank() ? UNKNOWN_VALUE_TYPE : valueType; - label = label == null ? "" : label; - data = data == null ? new CompoundTag() : data.copy(); - } - - @Override - public CompoundTag data() { - return data.copy(); - } - - public enum Direction { - INPUT, - OUTPUT - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/ProgramCodec.java b/src/main/java/dev/propulsionteam/computed/node/program/ProgramCodec.java deleted file mode 100644 index 3083863..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/ProgramCodec.java +++ /dev/null @@ -1,1092 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import dev.propulsionteam.computed.node.program.ConnectionModel.Waypoint; -import dev.propulsionteam.computed.node.program.NodeModel.PlaceholderStatus; -import dev.propulsionteam.computed.node.program.PortModel.Direction; -import dev.propulsionteam.computed.node.program.ProgramDiagnostic.Severity; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeMap; -import java.util.UUID; -import java.util.function.Predicate; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.Tag; -import net.minecraft.resources.ResourceLocation; - -/** NBT codec for {@link ComputedProgram}, including one-way migration from the legacy graph format. */ -public final class ProgramCodec { - private static final String FORMAT_VERSION = "formatVersion"; - private static final Predicate ASSUME_TYPES_AVAILABLE = ignored -> true; - - private ProgramCodec() {} - - /** Describes whether a read was a native v2 decode or a legacy migration. */ - public record DecodeResult(ComputedProgram program, boolean migrated, int sourceVersion) { - public DecodeResult { - Objects.requireNonNull(program, "program"); - } - } - - /** Result of adapting a v2 program to the positional format consumed by the transitional WGraph runtime. */ - public record LegacyBundleResult(CompoundTag bundle, List diagnostics) { - public LegacyBundleResult { - bundle = bundle == null ? new CompoundTag() : bundle.copy(); - diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); - } - - @Override - public CompoundTag bundle() { - return bundle.copy(); - } - } - - public static CompoundTag write(ComputedProgram program) { - return encode(program); - } - - public static CompoundTag encode(ComputedProgram program) { - Objects.requireNonNull(program, "program"); - CompoundTag root = new CompoundTag(); - root.putInt(FORMAT_VERSION, ComputedProgram.FORMAT_VERSION); - root.putLong("revision", program.revision()); - root.put("graph", encodeGraph(program.rootGraph())); - - ListTag functions = new ListTag(); - for (FunctionModel function : program.functions()) { - functions.add(encodeFunction(function)); - } - root.put("functions", functions); - - ListTag diagnostics = new ListTag(); - for (ProgramDiagnostic diagnostic : program.diagnostics()) { - diagnostics.add(encodeDiagnostic(diagnostic)); - } - root.put("diagnostics", diagnostics); - root.put("metadata", program.metadata()); - return root; - } - - /** - * Reconstructs {@code ComputerGraph}/{@code ComputerFunctions} for the transitional positional - * runtime. Connections using non-legacy stable port ids are skipped and reported because guessing - * an integer position could execute the wrong side effect. - */ - public static LegacyBundleResult toLegacyBundle(ComputedProgram program) { - Objects.requireNonNull(program, "program"); - List diagnostics = new ArrayList<>(); - CompoundTag bundle = new CompoundTag(); - bundle.put("ComputerGraph", toLegacyGraph(program.rootGraph(), diagnostics)); - - ListTag functions = new ListTag(); - for (FunctionModel function : program.functions()) { - CompoundTag tag = function.rawTag(); - tag.putUUID("Id", function.id()); - tag.putString("Name", function.name()); - tag.put("Body", toLegacyGraph(function.graph(), diagnostics)); - functions.add(tag); - } - bundle.put("ComputerFunctions", functions); - return new LegacyBundleResult(bundle, diagnostics); - } - - /** Convenience for callers that have already surfaced or intentionally ignore bridge diagnostics. */ - public static CompoundTag toLegacyBundleTag(ComputedProgram program) { - return toLegacyBundle(program).bundle(); - } - - public static ComputedProgram read(CompoundTag source) { - return decode(source).program(); - } - - public static ComputedProgram read(CompoundTag source, Predicate knownNodeType) { - return decode(source, knownNodeType).program(); - } - - public static DecodeResult decode(CompoundTag source) { - return decode(source, ASSUME_TYPES_AVAILABLE); - } - - /** - * Reads a program while using {@code knownNodeType} to identify recoverable missing-addon - * placeholders. Passing a registry lookup here allows placeholders to become resolved again as - * soon as their addon returns. - */ - public static DecodeResult decode(CompoundTag source, Predicate knownNodeType) { - Objects.requireNonNull(source, "source"); - Objects.requireNonNull(knownNodeType, "knownNodeType"); - - CompoundTag root = source; - if (source.contains("ComputedProgram", Tag.TAG_COMPOUND)) { - root = source.getCompound("ComputedProgram"); - } - - if (root.contains(FORMAT_VERSION)) { - int version = root.getInt(FORMAT_VERSION); - if (version == ComputedProgram.FORMAT_VERSION) { - return new DecodeResult(decodeV2(root, knownNodeType), false, version); - } - if (version > ComputedProgram.FORMAT_VERSION) { - throw new IllegalArgumentException("Unsupported Computed program format version: " + version); - } - } - return migrateLegacy(root, knownNodeType); - } - - private static ComputedProgram decodeV2(CompoundTag root, Predicate knownNodeType) { - List diagnostics = decodeDiagnostics(root.getList("diagnostics", Tag.TAG_COMPOUND)); - GraphModel graph = decodeV2Graph(root.getCompound("graph"), "root", knownNodeType, diagnostics); - - List functions = new ArrayList<>(); - ListTag functionTags = root.getList("functions", Tag.TAG_COMPOUND); - Set functionIds = new HashSet<>(); - for (int i = 0; i < functionTags.size(); i++) { - CompoundTag tag = functionTags.getCompound(i); - UUID requestedId = readUuid(tag, "id", stableUuid("v2/function/" + i)); - UUID id = uniqueUuid(requestedId, functionIds, "v2/function/" + i); - if (!id.equals(requestedId)) { - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "duplicate_function_id", - "Duplicate function id was replaced with a deterministic id", - null, - null, - null)); - } - GraphModel body = decodeV2Graph(tag.getCompound("graph"), "function/" + id, knownNodeType, diagnostics); - functions.add(new FunctionModel( - id, - tag.getString("name"), - body, - copyCompound(tag, "metadata"), - copyCompound(tag, "raw"))); - } - - long revision = root.contains("revision") ? Math.max(0L, root.getLong("revision")) : 0L; - return new ComputedProgram(revision, graph, functions, diagnostics, copyCompound(root, "metadata")); - } - - private static GraphModel decodeV2Graph( - CompoundTag tag, - String path, - Predicate knownNodeType, - List diagnostics) { - UUID graphId = readUuid(tag, "id", stableUuid("v2/graph/" + path)); - List nodes = new ArrayList<>(); - ListTag nodeTags = tag.getList("nodes", Tag.TAG_COMPOUND); - Set nodeIds = new HashSet<>(); - for (int i = 0; i < nodeTags.size(); i++) { - CompoundTag nodeTag = nodeTags.getCompound(i); - UUID requestedId = readUuid(nodeTag, "id", stableUuid(path + "/node/" + i)); - UUID id = uniqueUuid(requestedId, nodeIds, path + "/node/" + i); - if (!id.equals(requestedId)) { - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "duplicate_node_id", - "Duplicate node id was replaced with a deterministic id", - graphId, - id, - null)); - } - - String originalType = nodeTag.contains("originalType", Tag.TAG_STRING) - ? nodeTag.getString("originalType") - : nodeTag.getString("type"); - String canonicalType = canonicalType(nodeTag.getString("type")); - PlaceholderStatus status = placeholderStatus(canonicalType, originalType, knownNodeType); - if (status != PlaceholderStatus.RESOLVED) { - diagnostics.add(missingTypeDiagnostic(graphId, id, originalType, status)); - } - - List ports = decodeV2Ports(nodeTag.getList("ports", Tag.TAG_COMPOUND), graphId, id, diagnostics); - nodes.add(new NodeModel( - id, - canonicalType, - originalType, - nodeTag.getString("title"), - nodeTag.getInt("x"), - nodeTag.getInt("y"), - copyCompound(nodeTag, "properties"), - copyCompound(nodeTag, "state"), - ports, - status, - copyCompound(nodeTag, "raw"))); - } - - List connections = new ArrayList<>(); - ListTag connectionTags = tag.getList("connections", Tag.TAG_COMPOUND); - Set connectionIds = new HashSet<>(); - for (int i = 0; i < connectionTags.size(); i++) { - CompoundTag connectionTag = connectionTags.getCompound(i); - UUID requestedId = readUuid(connectionTag, "id", stableUuid(path + "/connection/" + i)); - UUID id = uniqueUuid(requestedId, connectionIds, path + "/connection/" + i); - UUID source = readUuid( - connectionTag, "sourceNode", stableUuid(path + "/connection/" + i + "/missing-source")); - UUID target = readUuid( - connectionTag, "targetNode", stableUuid(path + "/connection/" + i + "/missing-target")); - PortId sourcePort = readPortId( - connectionTag.getString("sourcePort"), PortId.legacyOutput(0), graphId, id, diagnostics); - PortId targetPort = readPortId( - connectionTag.getString("targetPort"), PortId.legacyInput(0), graphId, id, diagnostics); - connections.add(new ConnectionModel( - id, - source, - sourcePort, - target, - targetPort, - decodeWaypoints(connectionTag.getList("waypoints", Tag.TAG_COMPOUND)), - copyCompound(connectionTag, "raw"))); - } - - List sections = new ArrayList<>(); - ListTag sectionTags = tag.getList("sections", Tag.TAG_COMPOUND); - Set sectionIds = new HashSet<>(); - for (int i = 0; i < sectionTags.size(); i++) { - CompoundTag sectionTag = sectionTags.getCompound(i); - UUID id = uniqueUuid( - readUuid(sectionTag, "id", stableUuid(path + "/section/" + i)), - sectionIds, - path + "/section/" + i); - sections.add(decodeSection(sectionTag, id, "raw")); - } - return new GraphModel( - graphId, - nodes, - connections, - sections, - copyCompound(tag, "metadata"), - copyCompound(tag, "raw")); - } - - private static List decodeV2Ports( - ListTag portTags, - UUID graphId, - UUID nodeId, - List diagnostics) { - List ports = new ArrayList<>(); - Set identities = new HashSet<>(); - for (int i = 0; i < portTags.size(); i++) { - CompoundTag portTag = portTags.getCompound(i); - Direction direction = parseEnum(Direction.class, portTag.getString("direction"), Direction.INPUT); - PortId fallback = direction == Direction.INPUT ? PortId.legacyInput(i) : PortId.legacyOutput(i); - PortId id = readPortId(portTag.getString("id"), fallback, graphId, null, diagnostics); - String identity = direction + "\u0000" + id.value(); - if (!identities.add(identity)) { - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "duplicate_port_id", - "Node contains duplicate " + direction.name().toLowerCase() + " port id " + id, - graphId, - nodeId, - null)); - } - ports.add(new PortModel( - id, - direction, - portTag.getString("valueType"), - portTag.getString("label"), - copyCompound(portTag, "data"))); - } - return ports; - } - - private static DecodeResult migrateLegacy(CompoundTag root, Predicate knownNodeType) { - List diagnostics = new ArrayList<>(); - CompoundTag graphTag; - ListTag functions; - Set consumedRootKeys = new HashSet<>(); - - if (root.contains("ComputerGraph", Tag.TAG_COMPOUND)) { - graphTag = root.getCompound("ComputerGraph"); - functions = root.getList("ComputerFunctions", Tag.TAG_COMPOUND); - consumedRootKeys.add("ComputerGraph"); - consumedRootKeys.add("ComputerFunctions"); - } else if (root.contains("graph", Tag.TAG_COMPOUND)) { - graphTag = root.getCompound("graph"); - functions = root.getList("functions", Tag.TAG_COMPOUND); - consumedRootKeys.add("graph"); - consumedRootKeys.add("functions"); - consumedRootKeys.add("embeddedCustomNodes"); - } else if (root.contains("nodes", Tag.TAG_LIST)) { - graphTag = root; - functions = new ListTag(); - consumedRootKeys.addAll(root.getAllKeys()); - } else { - throw new IllegalArgumentException("NBT does not contain a Computed program or legacy graph"); - } - consumedRootKeys.add(FORMAT_VERSION); - consumedRootKeys.add("revision"); - - GraphModel graph = migrateLegacyGraph(graphTag, "root", knownNodeType, diagnostics); - List migratedFunctions = migrateLegacyFunctions(functions, knownNodeType, diagnostics); - - CompoundTag metadata = new CompoundTag(); - CompoundTag extras = copyExcept(root, consumedRootKeys); - if (!extras.isEmpty()) { - metadata.put("legacyRootExtras", extras); - } - metadata.putBoolean("migratedFromLegacy", true); - diagnostics.add(new ProgramDiagnostic( - Severity.INFO, - "legacy_program_migrated", - "Legacy graph was migrated to Computed program format 2", - graph.id(), - null, - null)); - - int sourceVersion = root.contains(FORMAT_VERSION) ? root.getInt(FORMAT_VERSION) : 0; - long revision = root.contains("revision") ? Math.max(0L, root.getLong("revision")) : 0L; - return new DecodeResult( - new ComputedProgram(revision, graph, migratedFunctions, diagnostics, metadata), - true, - sourceVersion); - } - - private static GraphModel migrateLegacyGraph( - CompoundTag legacy, - String path, - Predicate knownNodeType, - List diagnostics) { - UUID graphId = readUuid(legacy, "id", stableUuid("legacy/graph/" + path)); - List nodes = new ArrayList<>(); - Set usedNodeIds = new HashSet<>(); - Map nodeAliases = new HashMap<>(); - ListTag nodeTags = legacy.getList("nodes", Tag.TAG_COMPOUND); - - for (int i = 0; i < nodeTags.size(); i++) { - CompoundTag raw = nodeTags.getCompound(i).copy(); - String rawId = rawUuidText(raw, "id"); - UUID requestedId = parseUuid(rawId, stableUuid(path + "/legacy-node/" + i)); - UUID id = uniqueUuid(requestedId, usedNodeIds, path + "/legacy-node/" + i); - if (!id.equals(requestedId)) { - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "duplicate_node_id", - "Legacy duplicate node id was replaced with a deterministic id", - graphId, - id, - null)); - } - if (!rawId.isBlank()) { - nodeAliases.putIfAbsent(rawId, id); - } - nodeAliases.putIfAbsent(id.toString(), id); - - String originalType = firstString(raw, "typeId", "type"); - String canonicalType = canonicalType(originalType); - PlaceholderStatus status = placeholderStatus(canonicalType, originalType, knownNodeType); - if (status != PlaceholderStatus.RESOLVED) { - diagnostics.add(missingTypeDiagnostic(graphId, id, originalType, status)); - } else if (!canonicalType.equals(originalType)) { - diagnostics.add(new ProgramDiagnostic( - Severity.INFO, - "legacy_type_renamed", - "Migrated node type " + originalType + " to " + canonicalType, - graphId, - id, - null)); - } - - CompoundTag properties = extractLegacyProperties(raw); - CompoundTag state = extractLegacyState(raw); - if (raw.contains("inner", Tag.TAG_COMPOUND)) { - GraphModel inner = migrateLegacyGraph( - raw.getCompound("inner"), path + "/node/" + id + "/inner", knownNodeType, diagnostics); - state.put("innerGraph", encodeGraph(inner)); - } - - List ports = new ArrayList<>(); - migrateLegacyPorts(raw.getList("inputs", Tag.TAG_COMPOUND), Direction.INPUT, ports); - migrateLegacyPorts(raw.getList("outputs", Tag.TAG_COMPOUND), Direction.OUTPUT, ports); - nodes.add(new NodeModel( - id, - canonicalType, - originalType, - raw.getString("title"), - raw.getInt("x"), - raw.getInt("y"), - properties, - state, - ports, - status, - raw)); - } - - List connections = new ArrayList<>(); - ListTag connectionTags = legacy.contains("conns", Tag.TAG_LIST) - ? legacy.getList("conns", Tag.TAG_COMPOUND) - : legacy.getList("connections", Tag.TAG_COMPOUND); - Set usedConnectionIds = new HashSet<>(); - for (int i = 0; i < connectionTags.size(); i++) { - CompoundTag raw = connectionTags.getCompound(i).copy(); - UUID id = uniqueUuid( - readUuid(raw, "id", stableUuid(path + "/legacy-connection/" + i)), - usedConnectionIds, - path + "/legacy-connection/" + i); - String rawSource = firstUuidText(raw, "src", "sourceNode"); - String rawTarget = firstUuidText(raw, "tgt", "targetNode"); - UUID source = resolveLegacyEndpoint(rawSource, nodeAliases, path + "/connection/" + i + "/source"); - UUID target = resolveLegacyEndpoint(rawTarget, nodeAliases, path + "/connection/" + i + "/target"); - if (!containsNode(nodes, source) || !containsNode(nodes, target)) { - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "dangling_connection", - "Legacy connection references a missing node; it was retained for repair", - graphId, - null, - id)); - } - - NodeModel sourceNode = findNode(nodes, source); - NodeModel targetNode = findNode(nodes, target); - PortId sourcePort = legacyPortId(raw, true, sourceNode); - PortId targetPort = legacyPortId(raw, false, targetNode); - ListTag waypointTags = raw.contains("wps", Tag.TAG_LIST) - ? raw.getList("wps", Tag.TAG_COMPOUND) - : raw.getList("waypoints", Tag.TAG_COMPOUND); - connections.add(new ConnectionModel( - id, - source, - sourcePort, - target, - targetPort, - decodeWaypoints(waypointTags), - raw)); - } - - List sections = new ArrayList<>(); - ListTag sectionTags = legacy.getList("sections", Tag.TAG_COMPOUND); - Set usedSectionIds = new HashSet<>(); - for (int i = 0; i < sectionTags.size(); i++) { - CompoundTag raw = sectionTags.getCompound(i).copy(); - UUID id = uniqueUuid( - readUuid(raw, "id", stableUuid(path + "/legacy-section/" + i)), - usedSectionIds, - path + "/legacy-section/" + i); - sections.add(decodeLegacySection(raw, id)); - } - - CompoundTag metadata = copyExcept(legacy, Set.of("id", "nodes", "conns", "connections", "sections")); - return new GraphModel(graphId, nodes, connections, sections, metadata, legacy.copy()); - } - - private static List migrateLegacyFunctions( - ListTag functionTags, - Predicate knownNodeType, - List diagnostics) { - List functions = new ArrayList<>(); - Set usedIds = new HashSet<>(); - for (int i = 0; i < functionTags.size(); i++) { - CompoundTag raw = functionTags.getCompound(i).copy(); - String rawId = firstUuidText(raw, "Id", "id"); - UUID requestedId = parseUuid(rawId, stableUuid("legacy/function/" + i)); - UUID id = uniqueUuid(requestedId, usedIds, "legacy/function/" + i); - String name = firstString(raw, "Name", "name"); - CompoundTag body = firstCompound(raw, "Body", "body", "graph"); - GraphModel graph = migrateLegacyGraph(body, "function/" + id, knownNodeType, diagnostics); - CompoundTag metadata = copyExcept(raw, Set.of("Id", "id", "Name", "name", "Body", "body", "graph")); - functions.add(new FunctionModel(id, name, graph, metadata, raw)); - } - return functions; - } - - private static CompoundTag toLegacyGraph(GraphModel graph, List diagnostics) { - CompoundTag legacy = graph.rawTag(); - ListTag nodes = new ListTag(); - Map nodeIndex = new HashMap<>(); - for (NodeModel node : graph.nodes()) { - nodeIndex.putIfAbsent(node.id(), node); - CompoundTag tag = node.rawTag(); - tag.putString("typeId", node.typeId()); - tag.putString("id", node.id().toString()); - tag.putString("title", node.title()); - tag.putInt("x", node.x()); - tag.putInt("y", node.y()); - - tag.put("inputs", toLegacyPortList(graph, node, Direction.INPUT, diagnostics)); - tag.put("outputs", toLegacyPortList(graph, node, Direction.OUTPUT, diagnostics)); - - CompoundTag properties = node.properties(); - if (properties.contains("legacyElements", Tag.TAG_LIST)) { - tag.put("elements", properties.getList("legacyElements", Tag.TAG_COMPOUND).copy()); - } else if (properties.contains("elements", Tag.TAG_LIST)) { - tag.put("elements", properties.getList("elements", Tag.TAG_COMPOUND).copy()); - } - if (!properties.isEmpty()) { - tag.put("computedPropertiesV2", properties.copy()); - } - - CompoundTag state = node.state(); - Set structuralKeys = Set.of( - "typeId", "type", "id", "title", "x", "y", "inputs", "outputs", "elements", "properties"); - for (String key : state.getAllKeys()) { - if (!structuralKeys.contains(key) && !"innerGraph".equals(key)) { - Tag value = state.get(key); - if (value != null) tag.put(key, value.copy()); - } - } - if (!state.isEmpty()) { - tag.put("computedStateV2", state.copy()); - } - if (state.contains("innerGraph", Tag.TAG_COMPOUND)) { - try { - GraphModel inner = decodeV2Graph( - state.getCompound("innerGraph"), - "legacy-bridge/node/" + node.id() + "/inner", - ASSUME_TYPES_AVAILABLE, - diagnostics); - tag.put("inner", toLegacyGraph(inner, diagnostics)); - } catch (RuntimeException exception) { - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "legacy_inner_graph_unrepresentable", - "Nested function graph could not be adapted to the transitional runtime: " - + exception.getMessage(), - graph.id(), - node.id(), - null)); - } - } - if (node.isPlaceholder()) { - tag.putBoolean("ComputedMissingType", true); - diagnostics.add(new ProgramDiagnostic( - Severity.WARNING, - "legacy_placeholder_unavailable", - "The transitional runtime cannot instantiate missing node type " + node.originalTypeId() - + "; its raw tag remains in the bundle", - graph.id(), - node.id(), - null)); - } - nodes.add(tag); - } - legacy.put("nodes", nodes); - - ListTag connections = new ListTag(); - for (ConnectionModel connection : graph.connections()) { - NodeModel sourceNode = nodeIndex.get(connection.sourceNode()); - NodeModel targetNode = nodeIndex.get(connection.targetNode()); - Integer sourceIndex = legacyPortIndex(sourceNode, connection.sourcePort(), Direction.OUTPUT); - Integer targetIndex = legacyPortIndex(targetNode, connection.targetPort(), Direction.INPUT); - boolean validEndpoint = sourceNode != null - && targetNode != null - && sourceIndex != null - && targetIndex != null - && sourceNode.port(connection.sourcePort(), Direction.OUTPUT).isPresent() - && targetNode.port(connection.targetPort(), Direction.INPUT).isPresent(); - if (!validEndpoint) { - CompoundTag details = new CompoundTag(); - details.putString("sourcePort", connection.sourcePort().value()); - details.putString("targetPort", connection.targetPort().value()); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "legacy_connection_unrepresentable", - "Connection was skipped because the positional runtime cannot safely address its stable ports", - graph.id(), - null, - connection.id(), - details)); - continue; - } - - CompoundTag tag = connection.rawTag(); - tag.putString("src", connection.sourceNode().toString()); - tag.putInt("srcP", sourceIndex); - tag.putString("sourcePort", connection.sourcePort().value()); - tag.putString("tgt", connection.targetNode().toString()); - tag.putInt("tgtP", targetIndex); - tag.putString("targetPort", connection.targetPort().value()); - if (connection.waypoints().isEmpty()) { - tag.remove("wps"); - } else { - ListTag waypoints = new ListTag(); - for (Waypoint waypoint : connection.waypoints()) { - CompoundTag point = new CompoundTag(); - point.putInt("x", roundedInt(waypoint.x())); - point.putInt("y", roundedInt(waypoint.y())); - waypoints.add(point); - } - tag.put("wps", waypoints); - } - connections.add(tag); - } - legacy.put("conns", connections); - legacy.remove("connections"); - - ListTag sections = new ListTag(); - for (SectionModel section : graph.sections()) { - CompoundTag tag = section.rawTag(); - tag.putString("id", section.id().toString()); - tag.putString("name", section.name()); - tag.putInt("x", section.x()); - tag.putInt("y", section.y()); - tag.putInt("w", section.width()); - tag.putInt("h", section.height()); - tag.putInt("bodyArgb", section.bodyColorArgb()); - tag.putInt("layer", section.layer()); - sections.add(tag); - } - legacy.put("sections", sections); - return legacy; - } - - private static ListTag toLegacyPortList( - GraphModel graph, - NodeModel node, - Direction direction, - List diagnostics) { - ListTag result = new ListTag(); - Set seen = new HashSet<>(); - for (PortModel port : node.ports()) { - if (port.direction() != direction) continue; - if (!seen.add(port.id().value())) { - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "legacy_port_key_collision", - "Multiple ports share stable id " + port.id() + " and cannot be addressed safely", - graph.id(), - node.id(), - null)); - continue; - } - CompoundTag tag = port.data(); - tag.putString("portKey", port.id().value()); - tag.putString("name", port.label()); - if (!tag.contains("dataType", Tag.TAG_STRING)) { - tag.putString("dataType", port.valueType()); - } - result.add(tag); - } - return result; - } - - private static Integer legacyPortIndex(NodeModel node, PortId portId, Direction direction) { - if (node == null) return null; - int index = 0; - for (PortModel port : node.ports()) { - if (port.direction() != direction) continue; - if (port.id().equals(portId)) return index; - index++; - } - return null; - } - - private static int roundedInt(double value) { - if (!Double.isFinite(value)) return 0; - if (value <= Integer.MIN_VALUE) return Integer.MIN_VALUE; - if (value >= Integer.MAX_VALUE) return Integer.MAX_VALUE; - return (int) Math.round(value); - } - - private static CompoundTag encodeFunction(FunctionModel function) { - CompoundTag tag = new CompoundTag(); - tag.putUUID("id", function.id()); - tag.putString("name", function.name()); - tag.put("graph", encodeGraph(function.graph())); - tag.put("metadata", function.metadata()); - tag.put("raw", function.rawTag()); - return tag; - } - - private static CompoundTag encodeGraph(GraphModel graph) { - CompoundTag tag = new CompoundTag(); - tag.putUUID("id", graph.id()); - - ListTag nodes = new ListTag(); - for (NodeModel node : graph.nodes()) { - CompoundTag nodeTag = new CompoundTag(); - nodeTag.putUUID("id", node.id()); - nodeTag.putString("type", node.typeId()); - nodeTag.putString("originalType", node.originalTypeId()); - nodeTag.putString("title", node.title()); - nodeTag.putInt("x", node.x()); - nodeTag.putInt("y", node.y()); - nodeTag.putString("placeholder", node.placeholderStatus().name()); - nodeTag.put("properties", node.properties()); - nodeTag.put("state", node.state()); - nodeTag.put("raw", node.rawTag()); - - ListTag ports = new ListTag(); - for (PortModel port : node.ports()) { - CompoundTag portTag = new CompoundTag(); - portTag.putString("id", port.id().value()); - portTag.putString("direction", port.direction().name()); - portTag.putString("valueType", port.valueType()); - portTag.putString("label", port.label()); - portTag.put("data", port.data()); - ports.add(portTag); - } - nodeTag.put("ports", ports); - nodes.add(nodeTag); - } - tag.put("nodes", nodes); - - ListTag connections = new ListTag(); - for (ConnectionModel connection : graph.connections()) { - CompoundTag connectionTag = new CompoundTag(); - connectionTag.putUUID("id", connection.id()); - connectionTag.putUUID("sourceNode", connection.sourceNode()); - connectionTag.putString("sourcePort", connection.sourcePort().value()); - connectionTag.putUUID("targetNode", connection.targetNode()); - connectionTag.putString("targetPort", connection.targetPort().value()); - ListTag waypoints = new ListTag(); - for (Waypoint waypoint : connection.waypoints()) { - CompoundTag waypointTag = new CompoundTag(); - waypointTag.putDouble("x", waypoint.x()); - waypointTag.putDouble("y", waypoint.y()); - waypoints.add(waypointTag); - } - connectionTag.put("waypoints", waypoints); - connectionTag.put("raw", connection.rawTag()); - connections.add(connectionTag); - } - tag.put("connections", connections); - - ListTag sections = new ListTag(); - for (SectionModel section : graph.sections()) { - CompoundTag sectionTag = new CompoundTag(); - sectionTag.putUUID("id", section.id()); - sectionTag.putString("name", section.name()); - sectionTag.putInt("x", section.x()); - sectionTag.putInt("y", section.y()); - sectionTag.putInt("width", section.width()); - sectionTag.putInt("height", section.height()); - sectionTag.putInt("bodyColorArgb", section.bodyColorArgb()); - sectionTag.putInt("layer", section.layer()); - sectionTag.put("raw", section.rawTag()); - sections.add(sectionTag); - } - tag.put("sections", sections); - tag.put("metadata", graph.metadata()); - tag.put("raw", graph.rawTag()); - return tag; - } - - private static CompoundTag encodeDiagnostic(ProgramDiagnostic diagnostic) { - CompoundTag tag = new CompoundTag(); - tag.putString("severity", diagnostic.severity().name()); - tag.putString("code", diagnostic.code()); - tag.putString("message", diagnostic.message()); - if (diagnostic.graphId() != null) tag.putUUID("graphId", diagnostic.graphId()); - if (diagnostic.nodeId() != null) tag.putUUID("nodeId", diagnostic.nodeId()); - if (diagnostic.connectionId() != null) tag.putUUID("connectionId", diagnostic.connectionId()); - tag.put("details", diagnostic.details()); - return tag; - } - - private static List decodeDiagnostics(ListTag tags) { - List diagnostics = new ArrayList<>(); - for (int i = 0; i < tags.size(); i++) { - CompoundTag tag = tags.getCompound(i); - diagnostics.add(new ProgramDiagnostic( - parseEnum(Severity.class, tag.getString("severity"), Severity.WARNING), - tag.getString("code"), - tag.getString("message"), - optionalUuid(tag, "graphId"), - optionalUuid(tag, "nodeId"), - optionalUuid(tag, "connectionId"), - copyCompound(tag, "details"))); - } - return diagnostics; - } - - private static SectionModel decodeSection(CompoundTag tag, UUID id, String rawKey) { - return new SectionModel( - id, - tag.getString("name"), - tag.getInt("x"), - tag.getInt("y"), - tag.getInt("width"), - tag.getInt("height"), - tag.contains("bodyColorArgb") - ? tag.getInt("bodyColorArgb") - : SectionModel.DEFAULT_BODY_COLOR_ARGB, - tag.getInt("layer"), - copyCompound(tag, rawKey)); - } - - private static SectionModel decodeLegacySection(CompoundTag raw, UUID id) { - int width = raw.contains("w") ? raw.getInt("w") : raw.getInt("width"); - int height = raw.contains("h") ? raw.getInt("h") : raw.getInt("height"); - int color = raw.contains("bodyArgb") - ? raw.getInt("bodyArgb") - : raw.contains("bodyColorArgb") - ? raw.getInt("bodyColorArgb") - : SectionModel.DEFAULT_BODY_COLOR_ARGB; - return new SectionModel( - id, - raw.getString("name"), - raw.getInt("x"), - raw.getInt("y"), - width, - height, - color, - raw.getInt("layer"), - raw); - } - - private static void migrateLegacyPorts(ListTag tags, Direction direction, List destination) { - for (int i = 0; i < tags.size(); i++) { - CompoundTag raw = tags.getCompound(i).copy(); - PortId fallback = direction == Direction.INPUT ? PortId.legacyInput(i) : PortId.legacyOutput(i); - PortId id = fallback; - if (raw.contains("portKey", Tag.TAG_STRING)) { - try { - id = new PortId(raw.getString("portKey")); - } catch (IllegalArgumentException ignored) { - id = fallback; - } - } - String label = raw.contains("name", Tag.TAG_STRING) - ? raw.getString("name") - : (direction == Direction.INPUT ? "Input " : "Output ") + i; - destination.add(new PortModel(id, direction, inferLegacyValueType(raw), label, raw)); - } - } - - private static String inferLegacyValueType(CompoundTag raw) { - if (raw.contains("dataType", Tag.TAG_STRING)) return raw.getString("dataType").toLowerCase(); - if (raw.contains("s", Tag.TAG_STRING)) return "string"; - if (raw.contains("value")) return "number"; - return "widget"; - } - - private static CompoundTag extractLegacyProperties(CompoundTag raw) { - CompoundTag properties = raw.contains("computedPropertiesV2", Tag.TAG_COMPOUND) - ? raw.getCompound("computedPropertiesV2").copy() - : copyCompound(raw, "properties"); - if (raw.contains("elements", Tag.TAG_LIST)) { - properties.put("legacyElements", raw.getList("elements", Tag.TAG_COMPOUND).copy()); - } - return properties; - } - - private static CompoundTag extractLegacyState(CompoundTag raw) { - CompoundTag state = raw.contains("computedStateV2", Tag.TAG_COMPOUND) - ? raw.getCompound("computedStateV2").copy() - : copyCompound(raw, "state"); - Set structural = Set.of( - "typeId", "type", "id", "title", "x", "y", "inputs", "outputs", "elements", "properties", "state", - "computedPropertiesV2", "computedStateV2", "ComputedMissingType"); - for (String key : raw.getAllKeys()) { - if (!structural.contains(key)) { - Tag value = raw.get(key); - if (value != null) state.put(key, value.copy()); - } - } - return state; - } - - private static PortId legacyPortId(CompoundTag raw, boolean source, NodeModel endpoint) { - String stableKey = firstString( - raw, - source ? "sourcePort" : "targetPort", - source ? "srcPort" : "tgtPort"); - int index = raw.contains(source ? "srcP" : "tgtP") ? raw.getInt(source ? "srcP" : "tgtP") : 0; - if (!stableKey.isBlank()) { - try { - PortId candidate = new PortId(stableKey); - Direction direction = source ? Direction.OUTPUT : Direction.INPUT; - if (endpoint != null && endpoint.port(candidate, direction).isPresent()) { - return candidate; - } - } catch (IllegalArgumentException ignored) { - // Fall through to the lossless positional migration key. - } - } - if (endpoint != null && index >= 0) { - Direction direction = source ? Direction.OUTPUT : Direction.INPUT; - int positionalIndex = 0; - for (PortModel port : endpoint.ports()) { - if (port.direction() != direction) continue; - if (positionalIndex++ == index) return port.id(); - } - } - return source ? PortId.legacyOutput(index) : PortId.legacyInput(index); - } - - private static List decodeWaypoints(ListTag tags) { - List waypoints = new ArrayList<>(); - for (int i = 0; i < tags.size(); i++) { - CompoundTag tag = tags.getCompound(i); - waypoints.add(new Waypoint(tag.getDouble("x"), tag.getDouble("y"))); - } - return waypoints; - } - - private static ProgramDiagnostic missingTypeDiagnostic( - UUID graphId, UUID nodeId, String originalType, PlaceholderStatus status) { - String message = status == PlaceholderStatus.MALFORMED_TYPE - ? "Node has a missing or malformed type id and was preserved as a placeholder" - : "Node type " + originalType + " is unavailable and was preserved as a placeholder"; - return new ProgramDiagnostic( - Severity.ERROR, - status == PlaceholderStatus.MALFORMED_TYPE ? "malformed_node_type" : "missing_node_type", - message, - graphId, - nodeId, - null); - } - - private static PlaceholderStatus placeholderStatus( - String canonicalType, String originalType, Predicate knownNodeType) { - if (originalType == null || originalType.isBlank() || !isValidType(originalType)) { - return PlaceholderStatus.MALFORMED_TYPE; - } - try { - return knownNodeType.test(canonicalType) ? PlaceholderStatus.RESOLVED : PlaceholderStatus.MISSING_TYPE; - } catch (RuntimeException ignored) { - return PlaceholderStatus.MISSING_TYPE; - } - } - - /** Rewrites only the old built-in namespace; arbitrary addon namespaces are left untouched. */ - public static String canonicalType(String rawType) { - if (rawType == null || rawType.isBlank()) return "computed:missing"; - try { - ResourceLocation id = ResourceLocation.parse(rawType); - if ("websnodelib".equals(id.getNamespace())) { - return ResourceLocation.fromNamespaceAndPath("computed", id.getPath()).toString(); - } - return id.toString(); - } catch (RuntimeException ignored) { - return "computed:missing"; - } - } - - private static boolean isValidType(String type) { - try { - ResourceLocation.parse(type); - return true; - } catch (RuntimeException ignored) { - return false; - } - } - - private static PortId readPortId( - String raw, - PortId fallback, - UUID graphId, - UUID connectionId, - List diagnostics) { - try { - return new PortId(raw); - } catch (RuntimeException ignored) { - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "invalid_port_id", - "Invalid stable port id was replaced with " + fallback, - graphId, - null, - connectionId)); - return fallback; - } - } - - private static UUID resolveLegacyEndpoint(String raw, Map aliases, String fallbackSeed) { - UUID alias = aliases.get(raw); - if (alias != null) return alias; - return parseUuid(raw, stableUuid(fallbackSeed)); - } - - private static boolean containsNode(List nodes, UUID id) { - return nodes.stream().anyMatch(node -> node.id().equals(id)); - } - - private static NodeModel findNode(List nodes, UUID id) { - return nodes.stream().filter(node -> node.id().equals(id)).findFirst().orElse(null); - } - - private static UUID uniqueUuid(UUID requested, Set used, String fallbackSeed) { - UUID candidate = requested; - int attempt = 0; - while (!used.add(candidate)) { - candidate = stableUuid(fallbackSeed + "/duplicate/" + attempt++); - } - return candidate; - } - - private static UUID stableUuid(String seed) { - return UUID.nameUUIDFromBytes(("computed:" + seed).getBytes(StandardCharsets.UTF_8)); - } - - private static UUID parseUuid(String raw, UUID fallback) { - if (raw == null || raw.isBlank()) return fallback; - try { - return UUID.fromString(raw); - } catch (IllegalArgumentException ignored) { - return fallback; - } - } - - private static UUID readUuid(CompoundTag tag, String key, UUID fallback) { - UUID value = optionalUuid(tag, key); - return value == null ? fallback : value; - } - - private static UUID optionalUuid(CompoundTag tag, String key) { - try { - if (tag.hasUUID(key)) return tag.getUUID(key); - if (tag.contains(key, Tag.TAG_STRING)) return UUID.fromString(tag.getString(key)); - } catch (RuntimeException ignored) { - } - return null; - } - - private static String rawUuidText(CompoundTag tag, String key) { - UUID uuid = optionalUuid(tag, key); - if (uuid != null) return uuid.toString(); - return tag.contains(key, Tag.TAG_STRING) ? tag.getString(key) : ""; - } - - private static String firstUuidText(CompoundTag tag, String... keys) { - for (String key : keys) { - String value = rawUuidText(tag, key); - if (!value.isBlank()) return value; - } - return ""; - } - - private static String firstString(CompoundTag tag, String... keys) { - for (String key : keys) { - if (tag.contains(key, Tag.TAG_STRING)) return tag.getString(key); - } - return ""; - } - - private static CompoundTag firstCompound(CompoundTag tag, String... keys) { - for (String key : keys) { - if (tag.contains(key, Tag.TAG_COMPOUND)) return tag.getCompound(key); - } - return new CompoundTag(); - } - - private static CompoundTag copyCompound(CompoundTag owner, String key) { - return owner.contains(key, Tag.TAG_COMPOUND) ? owner.getCompound(key).copy() : new CompoundTag(); - } - - private static CompoundTag copyExcept(CompoundTag source, Set excludedKeys) { - CompoundTag result = new CompoundTag(); - for (String key : source.getAllKeys()) { - if (!excludedKeys.contains(key)) { - Tag value = source.get(key); - if (value != null) result.put(key, value.copy()); - } - } - return result; - } - - private static > E parseEnum(Class type, String value, E fallback) { - try { - return Enum.valueOf(type, value); - } catch (RuntimeException ignored) { - return fallback; - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/ProgramDiagnostic.java b/src/main/java/dev/propulsionteam/computed/node/program/ProgramDiagnostic.java deleted file mode 100644 index fb82e1c..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/ProgramDiagnostic.java +++ /dev/null @@ -1,38 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.Objects; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; - -/** Persistable migration, validation, or compilation diagnostic. Location ids may be {@code null}. */ -public record ProgramDiagnostic( - Severity severity, - String code, - String message, - UUID graphId, - UUID nodeId, - UUID connectionId, - CompoundTag details) { - - public ProgramDiagnostic { - Objects.requireNonNull(severity, "severity"); - code = code == null || code.isBlank() ? "unknown" : code; - message = message == null ? "" : message; - details = details == null ? new CompoundTag() : details.copy(); - } - - public ProgramDiagnostic(Severity severity, String code, String message, UUID graphId, UUID nodeId, UUID connectionId) { - this(severity, code, message, graphId, nodeId, connectionId, new CompoundTag()); - } - - @Override - public CompoundTag details() { - return details.copy(); - } - - public enum Severity { - INFO, - WARNING, - ERROR - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/program/SectionModel.java b/src/main/java/dev/propulsionteam/computed/node/program/SectionModel.java deleted file mode 100644 index 7c9b72e..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/program/SectionModel.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import java.util.Objects; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; - -/** Editor grouping rectangle stored in graph space. */ -public record SectionModel( - UUID id, - String name, - int x, - int y, - int width, - int height, - int bodyColorArgb, - int layer, - CompoundTag rawTag) { - - public static final int DEFAULT_BODY_COLOR_ARGB = 0x221F2A40; - - public SectionModel { - Objects.requireNonNull(id, "id"); - name = name == null ? "" : name; - width = Math.max(1, width); - height = Math.max(1, height); - layer = Math.max(0, layer); - rawTag = rawTag == null ? new CompoundTag() : rawTag.copy(); - } - - @Override - public CompoundTag rawTag() { - return rawTag.copy(); - } -} diff --git a/src/main/java/dev/propulsionteam/computed/node/runtime/GraphAnalysis.java b/src/main/java/dev/propulsionteam/computed/node/runtime/GraphAnalysis.java deleted file mode 100644 index bf8ca3c..0000000 --- a/src/main/java/dev/propulsionteam/computed/node/runtime/GraphAnalysis.java +++ /dev/null @@ -1,344 +0,0 @@ -package dev.propulsionteam.computed.node.runtime; - -import dev.propulsionteam.computed.node.program.ConnectionModel; -import dev.propulsionteam.computed.node.program.GraphModel; -import dev.propulsionteam.computed.node.program.NodeModel; -import dev.propulsionteam.computed.node.program.PortModel; -import dev.propulsionteam.computed.node.program.ProgramDiagnostic; -import dev.propulsionteam.computed.node.program.ProgramDiagnostic.Severity; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.Deque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.PriorityQueue; -import java.util.Set; -import java.util.SortedSet; -import java.util.TreeSet; -import java.util.UUID; -import java.util.function.Predicate; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.StringTag; - -/** Deterministic validation, SCC detection, and execution ordering for a persistent graph. */ -public final class GraphAnalysis { - private GraphAnalysis() {} - - /** - * Analyzes combinational dependencies. Edges leaving a state-boundary node are deliberately not - * dependencies on that node's current inputs: its prior-state outputs are exposed before the - * ordered evaluation pass, so such a node safely breaks a feedback loop. - */ - public static AnalysisResult analyze(GraphModel graph, Predicate isStateBoundaryType) { - Objects.requireNonNull(graph, "graph"); - Objects.requireNonNull(isStateBoundaryType, "isStateBoundaryType"); - - List diagnostics = new ArrayList<>(); - Set disabledNodes = new HashSet<>(); - Set invalidConnections = new HashSet<>(); - Map nodes = new LinkedHashMap<>(); - - List sortedNodes = new ArrayList<>(graph.nodes()); - sortedNodes.sort(Comparator.comparing(NodeModel::id)); - for (NodeModel node : sortedNodes) { - if (nodes.putIfAbsent(node.id(), node) != null) { - disabledNodes.add(node.id()); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "duplicate_node_id", - "Duplicate node id makes all instances with that id ambiguous", - graph.id(), - node.id(), - null)); - } - if (node.isPlaceholder()) { - disabledNodes.add(node.id()); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "placeholder_node_disabled", - "Unavailable node type " + node.originalTypeId() + " is disabled until it can be resolved", - graph.id(), - node.id(), - null)); - } - validatePortIdentities(graph, node, disabledNodes, diagnostics); - } - - Map> adjacency = new HashMap<>(); - for (UUID nodeId : nodes.keySet()) adjacency.put(nodeId, new TreeSet<>()); - - List sortedConnections = new ArrayList<>(graph.connections()); - sortedConnections.sort(Comparator.comparing(ConnectionModel::id)); - Set seenConnectionIds = new HashSet<>(); - for (ConnectionModel connection : sortedConnections) { - if (!seenConnectionIds.add(connection.id())) { - invalidConnections.add(connection.id()); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "duplicate_connection_id", - "Duplicate connection id is ambiguous", - graph.id(), - null, - connection.id())); - continue; - } - NodeModel source = nodes.get(connection.sourceNode()); - NodeModel target = nodes.get(connection.targetNode()); - if (source == null || target == null) { - invalidConnections.add(connection.id()); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "dangling_connection", - "Connection references a node that is not present in the graph", - graph.id(), - null, - connection.id())); - continue; - } - - PortModel sourcePort = source.port(connection.sourcePort(), PortModel.Direction.OUTPUT).orElse(null); - PortModel targetPort = target.port(connection.targetPort(), PortModel.Direction.INPUT).orElse(null); - if (sourcePort == null || targetPort == null) { - invalidConnections.add(connection.id()); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "invalid_connection_port", - "Connection references a missing or directionally invalid port", - graph.id(), - null, - connection.id())); - continue; - } - if (!compatibleValueTypes(sourcePort.valueType(), targetPort.valueType())) { - invalidConnections.add(connection.id()); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "connection_type_mismatch", - "Cannot connect " + sourcePort.valueType() + " to " + targetPort.valueType(), - graph.id(), - null, - connection.id())); - continue; - } - if (disabledNodes.contains(source.id()) || disabledNodes.contains(target.id())) { - invalidConnections.add(connection.id()); - continue; - } - - // A state node publishes its previous committed state before this ordered pass. Downstream - // nodes therefore do not wait for its next-state calculation; incoming dependencies are - // retained so that next-state calculation observes this step's resolved inputs. - if (!safePredicateTest(isStateBoundaryType, source.typeId())) { - adjacency.get(source.id()).add(target.id()); - } - } - - List> cycles = findCombinationalCycles(nodes.keySet(), adjacency, disabledNodes); - for (List cycle : cycles) { - disabledNodes.addAll(cycle); - CompoundTag details = new CompoundTag(); - ListTag ids = new ListTag(); - for (UUID id : cycle) ids.add(StringTag.valueOf(id.toString())); - details.put("nodeIds", ids); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - cycle.size() == 1 ? "combinational_self_loop" : "combinational_cycle", - "Combinational cycle is disabled; its nodes and connections remain editable", - graph.id(), - cycle.size() == 1 ? cycle.getFirst() : null, - null, - details)); - } - - for (ConnectionModel connection : sortedConnections) { - if (disabledNodes.contains(connection.sourceNode()) || disabledNodes.contains(connection.targetNode())) { - invalidConnections.add(connection.id()); - } - } - - List topologicalOrder = deterministicTopologicalOrder(nodes.keySet(), adjacency, disabledNodes); - return new AnalysisResult(topologicalOrder, disabledNodes, invalidConnections, cycles, diagnostics); - } - - private static void validatePortIdentities( - GraphModel graph, - NodeModel node, - Set disabledNodes, - List diagnostics) { - Set identities = new HashSet<>(); - for (PortModel port : node.ports()) { - String identity = port.direction() + "\u0000" + port.id().value(); - if (!identities.add(identity)) { - disabledNodes.add(node.id()); - diagnostics.add(new ProgramDiagnostic( - Severity.ERROR, - "duplicate_port_id", - "Duplicate stable port id " + port.id() + " makes the node schema ambiguous", - graph.id(), - node.id(), - null)); - } - } - } - - /** Runtime-compatible value types, including Computed's documented number-to-string coercion. */ - public static boolean compatibleValueTypes(String source, String target) { - return PortModel.UNKNOWN_VALUE_TYPE.equals(source) - || PortModel.UNKNOWN_VALUE_TYPE.equals(target) - || source.equals(target) - || ("number".equals(source) && "string".equals(target)); - } - - private static boolean safePredicateTest(Predicate predicate, String type) { - try { - return predicate.test(type); - } catch (RuntimeException ignored) { - return false; - } - } - - private static List> findCombinationalCycles( - Set nodeIds, - Map> adjacency, - Set alreadyDisabled) { - Tarjan tarjan = new Tarjan(adjacency, alreadyDisabled); - List orderedNodes = new ArrayList<>(nodeIds); - Collections.sort(orderedNodes); - for (UUID node : orderedNodes) { - if (!alreadyDisabled.contains(node)) tarjan.visitIfNeeded(node); - } - - List> cycles = new ArrayList<>(); - for (List component : tarjan.components()) { - boolean selfLoop = component.size() == 1 - && adjacency.getOrDefault(component.getFirst(), Collections.emptySortedSet()) - .contains(component.getFirst()); - if (component.size() > 1 || selfLoop) { - List sorted = new ArrayList<>(component); - Collections.sort(sorted); - cycles.add(List.copyOf(sorted)); - } - } - cycles.sort(Comparator.comparing(component -> component.getFirst())); - return List.copyOf(cycles); - } - - private static List deterministicTopologicalOrder( - Set nodeIds, - Map> adjacency, - Set disabledNodes) { - Map indegree = new HashMap<>(); - for (UUID node : nodeIds) { - if (!disabledNodes.contains(node)) indegree.put(node, 0); - } - for (Map.Entry> entry : adjacency.entrySet()) { - if (!indegree.containsKey(entry.getKey())) continue; - for (UUID target : entry.getValue()) { - if (indegree.containsKey(target)) indegree.merge(target, 1, Integer::sum); - } - } - - PriorityQueue ready = new PriorityQueue<>(); - indegree.forEach((node, degree) -> { - if (degree == 0) ready.add(node); - }); - - List order = new ArrayList<>(indegree.size()); - while (!ready.isEmpty()) { - UUID node = ready.remove(); - order.add(node); - for (UUID target : adjacency.getOrDefault(node, Collections.emptySortedSet())) { - if (!indegree.containsKey(target)) continue; - int remaining = indegree.merge(target, -1, Integer::sum); - if (remaining == 0) ready.add(target); - } - } - return List.copyOf(order); - } - - public record AnalysisResult( - List topologicalOrder, - Set disabledNodes, - Set invalidConnections, - List> combinationalCycles, - List diagnostics) { - public AnalysisResult { - topologicalOrder = List.copyOf(topologicalOrder); - disabledNodes = Collections.unmodifiableSet(new LinkedHashSet<>(sorted(disabledNodes))); - invalidConnections = Collections.unmodifiableSet(new LinkedHashSet<>(sorted(invalidConnections))); - combinationalCycles = combinationalCycles.stream().map(List::copyOf).toList(); - diagnostics = List.copyOf(diagnostics); - } - - private static List sorted(Set ids) { - List sorted = new ArrayList<>(ids); - Collections.sort(sorted); - return sorted; - } - - public boolean executable(UUID nodeId) { - return !disabledNodes.contains(nodeId); - } - } - - private static final class Tarjan { - private final Map> adjacency; - private final Set excluded; - private final Map indexByNode = new HashMap<>(); - private final Map lowLinkByNode = new HashMap<>(); - private final Deque stack = new ArrayDeque<>(); - private final Set onStack = new HashSet<>(); - private final List> components = new ArrayList<>(); - private int nextIndex; - - private Tarjan(Map> adjacency, Set excluded) { - this.adjacency = adjacency; - this.excluded = excluded; - } - - private void visitIfNeeded(UUID node) { - if (!indexByNode.containsKey(node)) strongConnect(node); - } - - private void strongConnect(UUID node) { - int index = nextIndex++; - indexByNode.put(node, index); - lowLinkByNode.put(node, index); - stack.push(node); - onStack.add(node); - - for (UUID target : adjacency.getOrDefault(node, Collections.emptySortedSet())) { - if (excluded.contains(target)) continue; - if (!indexByNode.containsKey(target)) { - strongConnect(target); - lowLinkByNode.put(node, Math.min(lowLinkByNode.get(node), lowLinkByNode.get(target))); - } else if (onStack.contains(target)) { - lowLinkByNode.put(node, Math.min(lowLinkByNode.get(node), indexByNode.get(target))); - } - } - - if (lowLinkByNode.get(node).equals(indexByNode.get(node))) { - List component = new ArrayList<>(); - UUID member; - do { - member = stack.pop(); - onStack.remove(member); - component.add(member); - } while (!member.equals(node)); - components.add(component); - } - } - - private List> components() { - return components; - } - } -} diff --git a/src/main/java/dev/propulsionteam/computed/persistence/LuaDefinitionClipboard.java b/src/main/java/dev/propulsionteam/computed/persistence/LuaDefinitionClipboard.java new file mode 100644 index 0000000..4e8152e --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/persistence/LuaDefinitionClipboard.java @@ -0,0 +1,22 @@ +package dev.propulsionteam.computed.persistence; + +import dev.propulsionteam.computed.graph.LuaDefinitionSource; + +public final class LuaDefinitionClipboard { + private LuaDefinitionClipboard() {} + + public static String exportSource(LuaDefinitionSource definition) { + return definition.source(); + } + + public static String importSource(String clipboard) { + String source = clipboard == null ? "" : clipboard.strip(); + if (source.startsWith("CMP1") || source.startsWith("CMP2")) { + throw new IllegalArgumentException("Legacy Computed clipboard programs are not supported"); + } + if (source.isBlank()) { + throw new IllegalArgumentException("Clipboard does not contain Lua source"); + } + return source; + } +} diff --git a/src/main/java/dev/propulsionteam/computed/persistence/ProgramV3Codec.java b/src/main/java/dev/propulsionteam/computed/persistence/ProgramV3Codec.java new file mode 100644 index 0000000..e28ea5a --- /dev/null +++ b/src/main/java/dev/propulsionteam/computed/persistence/ProgramV3Codec.java @@ -0,0 +1,259 @@ +package dev.propulsionteam.computed.persistence; + +import dev.propulsionteam.computed.graph.ComputedGraph; +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.GraphConnection; +import dev.propulsionteam.computed.graph.GraphNode; +import dev.propulsionteam.computed.graph.GraphPoint; +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import dev.propulsionteam.computed.graph.PortDirection; +import dev.propulsionteam.computed.graph.PortSnapshot; +import dev.propulsionteam.computed.lua.node.ConnectionType; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +public final class ProgramV3Codec { + private static final String FORMAT_VERSION = "formatVersion"; + + private ProgramV3Codec() {} + + public static CompoundTag encode(ComputedProgramV3 program) { + Objects.requireNonNull(program, "program"); + CompoundTag root = new CompoundTag(); + root.putInt(FORMAT_VERSION, ComputedProgramV3.FORMAT_VERSION); + root.putLong("revision", program.revision()); + root.put("graph", encodeGraph(program.rootGraph())); + ListTag library = new ListTag(); + program.library().values().stream() + .sorted(java.util.Comparator.comparing(LuaDefinitionSource::id)) + .map(ProgramV3Codec::encodeDefinition) + .forEach(library::add); + root.put("library", library); + ListTag states = new ListTag(); + program.persistentState().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + CompoundTag state = new CompoundTag(); + state.putUUID("node", entry.getKey()); + state.put("value", entry.getValue()); + states.add(state); + }); + root.put("states", states); + root.put("metadata", program.metadata()); + return root; + } + + public static LoadResult decode( + CompoundTag source, + String computerPosition, + Consumer warningSink) { + Objects.requireNonNull(source, "source"); + Consumer warnings = warningSink == null ? ignored -> {} : warningSink; + CompoundTag root = source.contains("ComputedProgram", Tag.TAG_COMPOUND) + ? source.getCompound("ComputedProgram") + : source; + if (!root.contains(FORMAT_VERSION)) { + return resetLegacy(root, 0, computerPosition, warnings); + } + int version = root.getInt(FORMAT_VERSION); + if (version < ComputedProgramV3.FORMAT_VERSION) { + return resetLegacy(root, version, computerPosition, warnings); + } + if (version > ComputedProgramV3.FORMAT_VERSION) { + throw new IllegalArgumentException("Unsupported Computed program format version: " + version); + } + return new LoadResult(decodeV3(root), false, version); + } + + public static ComputedProgramV3 decodeV3(CompoundTag root) { + ComputedGraph graph = decodeGraph(root.getCompound("graph")); + Map library = new LinkedHashMap<>(); + ListTag definitions = root.getList("library", Tag.TAG_COMPOUND); + for (int index = 0; index < definitions.size(); index++) { + LuaDefinitionSource definition = decodeDefinition(definitions.getCompound(index)); + if (library.putIfAbsent(definition.id(), definition) != null) { + throw new IllegalArgumentException("Duplicate Lua definition id: " + definition.id()); + } + } + Map state = new LinkedHashMap<>(); + ListTag states = root.getList("states", Tag.TAG_COMPOUND); + for (int index = 0; index < states.size(); index++) { + CompoundTag encoded = states.getCompound(index); + UUID nodeId = encoded.getUUID("node"); + if (state.putIfAbsent(nodeId, encoded.getCompound("value").copy()) != null) { + throw new IllegalArgumentException("Duplicate persistent state for node " + nodeId); + } + } + return new ComputedProgramV3( + root.getLong("revision"), + graph, + library, + state, + root.getCompound("metadata")); + } + + private static LoadResult resetLegacy( + CompoundTag source, + int version, + String computerPosition, + Consumer warnings) { + String position = computerPosition == null || computerPosition.isBlank() + ? "unknown position" + : computerPosition; + warnings.accept("Discarded legacy Computed program format " + + version + + " at " + + position + + "; initialized an empty format-3 program"); + UUID graphId = UUID.nameUUIDFromBytes( + ("computed:empty:" + position).getBytes(StandardCharsets.UTF_8)); + return new LoadResult(ComputedProgramV3.empty(graphId), true, version); + } + + private static CompoundTag encodeGraph(ComputedGraph graph) { + CompoundTag tag = new CompoundTag(); + tag.putUUID("id", graph.id()); + ListTag nodes = new ListTag(); + graph.nodes().forEach(node -> nodes.add(encodeNode(node))); + tag.put("nodes", nodes); + ListTag connections = new ListTag(); + graph.connections().forEach(connection -> connections.add(encodeConnection(connection))); + tag.put("connections", connections); + return tag; + } + + private static ComputedGraph decodeGraph(CompoundTag tag) { + UUID graphId = tag.hasUUID("id") ? tag.getUUID("id") : UUID.randomUUID(); + List nodes = new ArrayList<>(); + ListTag encodedNodes = tag.getList("nodes", Tag.TAG_COMPOUND); + for (int index = 0; index < encodedNodes.size(); index++) { + nodes.add(decodeNode(encodedNodes.getCompound(index))); + } + List connections = new ArrayList<>(); + ListTag encodedConnections = tag.getList("connections", Tag.TAG_COMPOUND); + for (int index = 0; index < encodedConnections.size(); index++) { + connections.add(decodeConnection(encodedConnections.getCompound(index))); + } + return new ComputedGraph(graphId, nodes, connections); + } + + private static CompoundTag encodeNode(GraphNode node) { + CompoundTag tag = new CompoundTag(); + tag.putUUID("id", node.id()); + tag.putString("definition", node.definitionId()); + tag.putString("hash", node.definitionHash()); + tag.putInt("x", node.x()); + tag.putInt("y", node.y()); + ListTag ports = new ListTag(); + node.ports().forEach(port -> { + CompoundTag encoded = new CompoundTag(); + encoded.putString("id", port.id()); + encoded.putString("direction", port.direction().name()); + encoded.putString("type", port.type().name()); + encoded.putString("label", port.label()); + ports.add(encoded); + }); + tag.put("ports", ports); + CompoundTag fields = new CompoundTag(); + node.fields().forEach(fields::put); + tag.put("fields", fields); + return tag; + } + + private static GraphNode decodeNode(CompoundTag tag) { + List ports = new ArrayList<>(); + ListTag encodedPorts = tag.getList("ports", Tag.TAG_COMPOUND); + for (int index = 0; index < encodedPorts.size(); index++) { + CompoundTag port = encodedPorts.getCompound(index); + ports.add(new PortSnapshot( + port.getString("id"), + PortDirection.valueOf(port.getString("direction")), + ConnectionType.valueOf(port.getString("type")), + port.getString("label"))); + } + Map fields = new LinkedHashMap<>(); + CompoundTag encodedFields = tag.getCompound("fields"); + for (String id : encodedFields.getAllKeys()) { + Tag value = encodedFields.get(id); + if (value instanceof CompoundTag compound) { + fields.put(id, compound.copy()); + } + } + return new GraphNode( + tag.getUUID("id"), + tag.getString("definition"), + tag.getString("hash"), + tag.getInt("x"), + tag.getInt("y"), + ports, + fields); + } + + private static CompoundTag encodeConnection(GraphConnection connection) { + CompoundTag tag = new CompoundTag(); + tag.putUUID("id", connection.id()); + tag.putUUID("sourceNode", connection.sourceNode()); + tag.putString("sourcePort", connection.sourcePort()); + tag.putUUID("targetNode", connection.targetNode()); + tag.putString("targetPort", connection.targetPort()); + ListTag waypoints = new ListTag(); + connection.waypoints().forEach(point -> { + CompoundTag encoded = new CompoundTag(); + encoded.putDouble("x", point.x()); + encoded.putDouble("y", point.y()); + waypoints.add(encoded); + }); + tag.put("waypoints", waypoints); + return tag; + } + + private static GraphConnection decodeConnection(CompoundTag tag) { + List waypoints = new ArrayList<>(); + ListTag encodedWaypoints = tag.getList("waypoints", Tag.TAG_COMPOUND); + for (int index = 0; index < encodedWaypoints.size(); index++) { + CompoundTag point = encodedWaypoints.getCompound(index); + waypoints.add(new GraphPoint(point.getDouble("x"), point.getDouble("y"))); + } + return new GraphConnection( + tag.getUUID("id"), + tag.getUUID("sourceNode"), + tag.getString("sourcePort"), + tag.getUUID("targetNode"), + tag.getString("targetPort"), + waypoints); + } + + private static CompoundTag encodeDefinition(LuaDefinitionSource definition) { + CompoundTag tag = new CompoundTag(); + tag.putInt("apiVersion", definition.apiVersion()); + tag.putString("id", definition.id()); + tag.putString("source", definition.source()); + tag.putString("hash", definition.hash()); + tag.putString("origin", definition.origin().name()); + return tag; + } + + private static LuaDefinitionSource decodeDefinition(CompoundTag tag) { + return new LuaDefinitionSource( + tag.getInt("apiVersion"), + tag.getString("id"), + tag.getString("source"), + tag.getString("hash"), + LuaDefinitionSource.Origin.valueOf(tag.getString("origin"))); + } + + public record LoadResult(ComputedProgramV3 program, boolean discardedLegacy, int sourceVersion) { + public LoadResult { + Objects.requireNonNull(program, "program"); + } + } +} diff --git a/src/main/resources/assets/computed/lang/en_us.json b/src/main/resources/assets/computed/lang/en_us.json index 8921d0f..a9a3f46 100644 --- a/src/main/resources/assets/computed/lang/en_us.json +++ b/src/main/resources/assets/computed/lang/en_us.json @@ -1,27 +1,10 @@ { - "gui.computed.lod.zoom_in": "Zoom in to edit ports and controls", "itemGroup.computed": "Computed", "block.computed.computer": "Computer", "block.computed.creative_computer": "Creative Computer", "block.computed.monitor": "Monitor", - "item.computed.computer.stored": "Stored Program — %s nodes, %s functions", - + "item.computed.computer.stored": "Stored Program - %s nodes, %s Lua definitions", "gui.computed.peripheral_not_available": "Not available", - "gui.computed.function_needs_peripheral": "Needs hardware", - "gui.computed.share.export_success": "Exported program string (%s chars) to clipboard", - "gui.computed.share.export_ready": "Export string ready (%s chars)", - "gui.computed.share.export_failed": "Failed to export program string", - "gui.computed.share.export_title": "Export Program To String", - "gui.computed.share.import_title": "Import Program From String", - "gui.computed.share.import_placeholder": "Paste your exported string here...", - "gui.computed.share.copy_button": "Copy", - "gui.computed.share.close_button": "Close", - "gui.computed.share.import_button": "Import", - "gui.computed.share.cancel_button": "Cancel", - "gui.computed.share.import_success": "Imported program. Embedded custom nodes: %s", - "gui.computed.share.import_success_legacy": "Imported legacy program string", - "gui.computed.share.import_failed": "Invalid or unsupported import string", - "computed.configuration.title": "Computed Configs", "computed.configuration.section.computed.common.toml": "Computed Configs", "computed.configuration.section.computed.common.toml.title": "Computed Configs" diff --git a/src/main/resources/computed/lua/nodes/flow/if.lua b/src/main/resources/computed/lua/nodes/flow/if.lua new file mode 100644 index 0000000..b0ab7c9 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/flow/if.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "computed:if_branch", "If") + +node:category("flow") +node:input("condition", "boolean", { default = false }) +node:output("true_branch", "boolean") +node:output("false_branch", "boolean") +node:on_run(function(ctx) + local condition = ctx:input("condition") + ctx:output("true_branch", condition) + ctx:output("false_branch", not condition) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/flow/switch.lua b/src/main/resources/computed/lua/nodes/flow/switch.lua new file mode 100644 index 0000000..01f447b --- /dev/null +++ b/src/main/resources/computed/lua/nodes/flow/switch.lua @@ -0,0 +1,12 @@ +local node = computed.node(1, "computed:switch", "Switch") + +node:category("flow") +node:input("select", "boolean", { default = false }) +node:input("a", "number", { default = 0 }) +node:input("b", "number", { default = 0 }) +node:output("value", "number") +node:on_run(function(ctx) + ctx:output("value", ctx:input("select") and ctx:input("b") or ctx:input("a")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/integration/computercraft/input.lua b/src/main/resources/computed/lua/nodes/integration/computercraft/input.lua new file mode 100644 index 0000000..d8e7deb --- /dev/null +++ b/src/main/resources/computed/lua/nodes/integration/computercraft/input.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "computed:cc_input", "CC Input") + +node:category("integration/computercraft/channels") +node:style("source") +node:execution("tick") +node:field("channel", "text", { default = "input" }) +node:output("value", "table") +node:on_run(function(ctx) + local endpoint = ctx:endpoint("computercraft:channel") + ctx:output("value", endpoint:call("read", ctx:field("channel"))) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/integration/computercraft/output.lua b/src/main/resources/computed/lua/nodes/integration/computercraft/output.lua new file mode 100644 index 0000000..29bb171 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/integration/computercraft/output.lua @@ -0,0 +1,12 @@ +local node = computed.node(1, "computed:cc_output", "CC Output") + +node:category("integration/computercraft/channels") +node:style("sink") +node:execution("input") +node:field("channel", "text", { default = "output" }) +node:input("value", "table") +node:on_run(function(ctx) + ctx:endpoint("computercraft:channel"):call("publish", ctx:field("channel"), ctx:input("value")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/integration/create/kinetic.lua b/src/main/resources/computed/lua/nodes/integration/create/kinetic.lua new file mode 100644 index 0000000..5dff68f --- /dev/null +++ b/src/main/resources/computed/lua/nodes/integration/create/kinetic.lua @@ -0,0 +1,17 @@ +local node = computed.node(1, "computed:create_kinetic", "Kinetic Sensor") + +node:category("integration/create/kinetics") +node:style("source") +node:execution("tick") +node:field("face", "direction", { default = "front" }) +node:output("speed", "number") +node:output("stress", "number") +node:output("capacity", "number") +node:on_run(function(ctx) + local create = ctx:endpoint("create:kinetic", ctx:field("face")) + ctx:output("speed", create:call("speed")) + ctx:output("stress", create:call("stress")) + ctx:output("capacity", create:call("capacity")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/integration/create/link_receiver.lua b/src/main/resources/computed/lua/nodes/integration/create/link_receiver.lua new file mode 100644 index 0000000..d349def --- /dev/null +++ b/src/main/resources/computed/lua/nodes/integration/create/link_receiver.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "computed:create_link_receiver", "Redstone Link Receiver") + +node:category("integration/create/redstone_link") +node:style("source") +node:execution("tick") +node:field("first", "item", { default = "minecraft:air", label = "First frequency" }) +node:field("second", "item", { default = "minecraft:air", label = "Second frequency" }) +node:output("strength", "number") +node:on_run(function(ctx) + local link = ctx:endpoint("create:redstone_link") + ctx:output("strength", link:call("receive", ctx:field("first"), ctx:field("second"))) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/integration/create/link_sender.lua b/src/main/resources/computed/lua/nodes/integration/create/link_sender.lua new file mode 100644 index 0000000..137f2e2 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/integration/create/link_sender.lua @@ -0,0 +1,17 @@ +local node = computed.node(1, "computed:create_link_sender", "Redstone Link Sender") + +node:category("integration/create/redstone_link") +node:style("sink") +node:execution("input") +node:field("first", "item", { default = "minecraft:air", label = "First frequency" }) +node:field("second", "item", { default = "minecraft:air", label = "Second frequency" }) +node:input("strength", "number") +node:on_run(function(ctx) + ctx:endpoint("create:redstone_link"):call( + "transmit", + ctx:field("first"), + ctx:field("second"), + ctx:input("strength")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/io/bool_to_level.lua b/src/main/resources/computed/lua/nodes/io/bool_to_level.lua new file mode 100644 index 0000000..02b6373 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/io/bool_to_level.lua @@ -0,0 +1,10 @@ +local node = computed.node(1, "computed:bool_to_level", "Boolean to Level") + +node:category("io") +node:input("value", "boolean", { default = false }) +node:output("level", "number") +node:on_run(function(ctx) + ctx:output("level", ctx:input("value") and 15 or 0) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/io/command.lua b/src/main/resources/computed/lua/nodes/io/command.lua new file mode 100644 index 0000000..fbaaf07 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/io/command.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "computed:command", "Run Command") + +node:category("io") +node:style("sink") +node:input("trigger", "boolean", { default = false }) +node:field("command", "text", { default = "" }) +node:on_run(function(ctx) + if ctx:input("trigger") then + local commands = ctx:endpoint("computed:command") + commands:call("run", ctx:field("command")) + end +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/io/display.lua b/src/main/resources/computed/lua/nodes/io/display.lua new file mode 100644 index 0000000..4e6e569 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/io/display.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "computed:display", "Display") + +node:category("io") +node:style("sink") +node:input("value", "number", { default = 0 }) +node:on_run(function(ctx) + ctx:set_state("unused", 0) +end) +node:state("unused", 0) + +return node diff --git a/src/main/resources/computed/lua/nodes/io/level_to_bool.lua b/src/main/resources/computed/lua/nodes/io/level_to_bool.lua new file mode 100644 index 0000000..2d4c141 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/io/level_to_bool.lua @@ -0,0 +1,18 @@ +local node = computed.node(1, "computed:level_to_bool", "Level to Boolean") + +node:category("io") +node:input("level", "number", { default = 0 }) +node:field("threshold", "number", { + default = 8, + min = 0, + max = 15, + label = "Threshold", + control = "slider", + step = 1 +}) +node:output("value", "boolean") +node:on_run(function(ctx) + ctx:output("value", ctx:input("level") >= ctx:field("threshold")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/io/rgb_preview.lua b/src/main/resources/computed/lua/nodes/io/rgb_preview.lua new file mode 100644 index 0000000..0cafa83 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/io/rgb_preview.lua @@ -0,0 +1,17 @@ +local node = computed.node(1, "computed:rgb_preview", "RGB Preview") + +node:category("io") +node:style("sink") +node:input("red", "number", { default = 0 }) +node:input("green", "number", { default = 0 }) +node:input("blue", "number", { default = 0 }) +node:on_run(function(ctx) + ctx:set_state("color", { + red = ctx:input("red"), + green = ctx:input("green"), + blue = ctx:input("blue") + }) +end) +node:state("color", { red = 0, green = 0, blue = 0 }) + +return node diff --git a/src/main/resources/computed/lua/nodes/logic/approximately.lua b/src/main/resources/computed/lua/nodes/logic/approximately.lua new file mode 100644 index 0000000..b538288 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/logic/approximately.lua @@ -0,0 +1,19 @@ +local node = computed.node(1, "computed:cmp_approx", "Approximately Equal") + +node:category("logic") +node:input("a", "number", { default = 0 }) +node:input("b", "number", { default = 0 }) +node:field("epsilon", "number", { + default = 0.5, + min = 0, + max = 15, + label = "Tolerance", + control = "slider", + step = 0.01 +}) +node:output("result", "boolean") +node:on_run(function(ctx) + ctx:output("result", math.abs(ctx:input("a") - ctx:input("b")) <= ctx:field("epsilon")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/logic/d_flipflop.lua b/src/main/resources/computed/lua/nodes/logic/d_flipflop.lua new file mode 100644 index 0000000..8c13bb1 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/logic/d_flipflop.lua @@ -0,0 +1,20 @@ +local node = computed.node(1, "computed:d_flipflop", "D Flip-Flop") + +node:category("state") +node:input("data", "boolean", { default = false }) +node:input("clock", "boolean", { default = false }) +node:output("q", "boolean") +node:state("q", false) +node:state("previous_clock", false) +node:on_run(function(ctx) + local clock = ctx:input("clock") + local q = ctx:state("q") + if clock and not ctx:state("previous_clock") then + q = ctx:input("data") + end + ctx:set_state("q", q) + ctx:set_state("previous_clock", clock) + ctx:output("q", q) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/logic/edge_fall.lua b/src/main/resources/computed/lua/nodes/logic/edge_fall.lua new file mode 100644 index 0000000..5aa2072 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/logic/edge_fall.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "computed:edge_fall", "Falling Edge") + +node:category("logic") +node:input("value", "boolean", { default = false }) +node:output("pulse", "boolean") +node:state("previous", false) +node:on_run(function(ctx) + local value = ctx:input("value") + ctx:output("pulse", not value and ctx:state("previous")) + ctx:set_state("previous", value) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/logic/edge_rise.lua b/src/main/resources/computed/lua/nodes/logic/edge_rise.lua new file mode 100644 index 0000000..bcb7598 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/logic/edge_rise.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "computed:edge_rise", "Rising Edge") + +node:category("logic") +node:input("value", "boolean", { default = false }) +node:output("pulse", "boolean") +node:state("previous", false) +node:on_run(function(ctx) + local value = ctx:input("value") + ctx:output("pulse", value and not ctx:state("previous")) + ctx:set_state("previous", value) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/logic/mux.lua b/src/main/resources/computed/lua/nodes/logic/mux.lua new file mode 100644 index 0000000..f37174e --- /dev/null +++ b/src/main/resources/computed/lua/nodes/logic/mux.lua @@ -0,0 +1,12 @@ +local node = computed.node(1, "computed:mux", "Multiplexer") + +node:category("logic") +node:input("select", "boolean", { default = false }) +node:input("a", "number", { default = 0 }) +node:input("b", "number", { default = 0 }) +node:output("result", "number") +node:on_run(function(ctx) + ctx:output("result", ctx:input("select") and ctx:input("b") or ctx:input("a")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/logic/not.lua b/src/main/resources/computed/lua/nodes/logic/not.lua new file mode 100644 index 0000000..45adcb8 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/logic/not.lua @@ -0,0 +1,10 @@ +local node = computed.node(1, "computed:logic_not", "Not") + +node:category("logic") +node:input("value", "boolean", { default = false }) +node:output("result", "boolean") +node:on_run(function(ctx) + ctx:output("result", not ctx:input("value")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/logic/schmitt.lua b/src/main/resources/computed/lua/nodes/logic/schmitt.lua new file mode 100644 index 0000000..738d24f --- /dev/null +++ b/src/main/resources/computed/lua/nodes/logic/schmitt.lua @@ -0,0 +1,34 @@ +local node = computed.node(1, "computed:schmitt", "Schmitt Trigger") + +node:category("logic") +node:input("value", "number", { default = 0 }) +node:field("low", "number", { + default = 5, + min = 0, + max = 15, + label = "Off threshold", + control = "slider", + step = 0.1 +}) +node:field("high", "number", { + default = 10, + min = 0, + max = 15, + label = "On threshold", + control = "slider", + step = 0.1 +}) +node:output("result", "boolean") +node:state("active", false) +node:on_run(function(ctx) + local active = ctx:state("active") + if active and ctx:input("value") <= ctx:field("low") then + active = false + elseif not active and ctx:input("value") >= ctx:field("high") then + active = true + end + ctx:set_state("active", active) + ctx:output("result", active) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/logic/sr_latch.lua b/src/main/resources/computed/lua/nodes/logic/sr_latch.lua new file mode 100644 index 0000000..fd0d579 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/logic/sr_latch.lua @@ -0,0 +1,19 @@ +local node = computed.node(1, "computed:sr_latch", "SR Latch") + +node:category("state") +node:input("set", "boolean", { default = false }) +node:input("reset", "boolean", { default = false }) +node:output("q", "boolean") +node:state("q", false) +node:on_run(function(ctx) + local q = ctx:state("q") + if ctx:input("reset") then + q = false + elseif ctx:input("set") then + q = true + end + ctx:set_state("q", q) + ctx:output("q", q) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/math/add.lua b/src/main/resources/computed/lua/nodes/math/add.lua new file mode 100644 index 0000000..30d7c3c --- /dev/null +++ b/src/main/resources/computed/lua/nodes/math/add.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "computed:add", "Add") + +node:category("math") +node:input("a", "number", { default = 0 }) +node:input("b", "number", { default = 0 }) +node:output("result", "number") +node:on_run(function(ctx) + ctx:output("result", ctx:input("a") + ctx:input("b")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/math/average.lua b/src/main/resources/computed/lua/nodes/math/average.lua new file mode 100644 index 0000000..c634954 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/math/average.lua @@ -0,0 +1,16 @@ +local node = computed.node(1, "computed:math_average", "Running Average") + +node:category("state") +node:input("value", "number", { default = 0 }) +node:output("mean", "number") +node:state("sum", 0) +node:state("count", 0) +node:on_run(function(ctx) + local sum = ctx:state("sum") + ctx:input("value") + local count = ctx:state("count") + 1 + ctx:set_state("sum", sum) + ctx:set_state("count", count) + ctx:output("mean", sum / count) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/math/clamp.lua b/src/main/resources/computed/lua/nodes/math/clamp.lua new file mode 100644 index 0000000..61679cf --- /dev/null +++ b/src/main/resources/computed/lua/nodes/math/clamp.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "computed:math_clamp", "Clamp") + +node:category("math") +node:input("value", "number", { default = 0 }) +node:input("minimum", "number", { default = 0 }) +node:input("maximum", "number", { default = 1 }) +node:output("result", "number") +node:on_run(function(ctx) + local minimum = ctx:input("minimum") + local maximum = ctx:input("maximum") + ctx:output("result", math.max(minimum, math.min(maximum, ctx:input("value")))) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/math/lerp.lua b/src/main/resources/computed/lua/nodes/math/lerp.lua new file mode 100644 index 0000000..d2b4239 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/math/lerp.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "computed:math_lerp", "Lerp") + +node:category("math") +node:input("a", "number", { default = 0 }) +node:input("b", "number", { default = 1 }) +node:input("amount", "number", { default = 0.5 }) +node:output("result", "number") +node:on_run(function(ctx) + local a = ctx:input("a") + ctx:output("result", a + (ctx:input("b") - a) * ctx:input("amount")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/math/map.lua b/src/main/resources/computed/lua/nodes/math/map.lua new file mode 100644 index 0000000..b99f5e8 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/math/map.lua @@ -0,0 +1,21 @@ +local node = computed.node(1, "computed:math_map", "Map Range") + +node:category("math") +node:input("value", "number", { default = 0 }) +node:input("input_minimum", "number", { default = 0 }) +node:input("input_maximum", "number", { default = 1 }) +node:input("output_minimum", "number", { default = 0 }) +node:input("output_maximum", "number", { default = 1 }) +node:output("result", "number") +node:on_run(function(ctx) + local input_minimum = ctx:input("input_minimum") + local input_maximum = ctx:input("input_maximum") + local result = ctx:input("output_minimum") + if input_maximum ~= input_minimum then + local ratio = (ctx:input("value") - input_minimum) / (input_maximum - input_minimum) + result = result + ratio * (ctx:input("output_maximum") - result) + end + ctx:output("result", result) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/math/random.lua b/src/main/resources/computed/lua/nodes/math/random.lua new file mode 100644 index 0000000..5bbdf6f --- /dev/null +++ b/src/main/resources/computed/lua/nodes/math/random.lua @@ -0,0 +1,15 @@ +local node = computed.node(1, "computed:math_random", "Random") + +node:category("math") +node:style("source") +node:execution("tick") +node:field("minimum", "number", { default = 0 }) +node:field("maximum", "number", { default = 1 }) +node:output("result", "number") +node:on_run(function(ctx) + local minimum = ctx:field("minimum") + local maximum = ctx:field("maximum") + ctx:output("result", minimum + math.random() * (maximum - minimum)) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/sources/color.lua b/src/main/resources/computed/lua/nodes/sources/color.lua new file mode 100644 index 0000000..6d611b9 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/sources/color.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "computed:color_source", "Color") + +node:category("utility") +node:style("source") +node:field("color", "color", { default = 4294928042 }) +node:output("color", "number") +node:on_run(function(ctx) + ctx:output("color", ctx:field("color")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/sources/constant.lua b/src/main/resources/computed/lua/nodes/sources/constant.lua new file mode 100644 index 0000000..6bcf1a9 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/sources/constant.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "computed:constant", "Constant") + +node:category("utility") +node:style("source") +node:field("value", "number", { default = 10, label = "Value" }) +node:output("value", "number") +node:on_run(function(ctx) + ctx:output("value", ctx:field("value")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/sources/oscillator.lua b/src/main/resources/computed/lua/nodes/sources/oscillator.lua new file mode 100644 index 0000000..faa573e --- /dev/null +++ b/src/main/resources/computed/lua/nodes/sources/oscillator.lua @@ -0,0 +1,31 @@ +local node = computed.node(1, "computed:oscillator", "Oscillator") + +node:category("math") +node:style("source") +node:execution("tick") +node:field("period", "number", { + default = 20, + min = 1, + max = 200, + label = "Period (ticks)", + control = "slider", + step = 1 +}) +node:field("amplitude", "number", { + default = 1, + min = 1, + max = 100, + label = "Amplitude", + control = "slider", + step = 1 +}) +node:output("value", "number") +node:on_run(function(ctx) + local period = math.max(1, ctx:field("period")) + ctx:output( + "value", + math.sin(ctx:tick() * math.pi * 2 / period) + * ctx:field("amplitude")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/sources/pulse.lua b/src/main/resources/computed/lua/nodes/sources/pulse.lua new file mode 100644 index 0000000..ea925c5 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/sources/pulse.lua @@ -0,0 +1,20 @@ +local node = computed.node(1, "computed:pulse", "Pulse") + +node:category("flow") +node:style("source") +node:execution("tick") +node:field("period", "number", { + default = 20, + min = 1, + max = 20, + label = "Cooldown (ticks)", + control = "slider", + step = 1 +}) +node:output("pulse", "boolean") +node:on_run(function(ctx) + local period = math.max(1, math.floor(ctx:field("period"))) + ctx:output("pulse", ctx:tick() % (period * 2) < period) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/sources/tick.lua b/src/main/resources/computed/lua/nodes/sources/tick.lua new file mode 100644 index 0000000..97dab7d --- /dev/null +++ b/src/main/resources/computed/lua/nodes/sources/tick.lua @@ -0,0 +1,31 @@ +local node = computed.node(1, "computed:tick", "Tick") + +node:category("flow") +node:style("source") +node:execution("tick") +node:field("rate", "number", { + default = 20, + min = 0, + max = 20, + label = "Rate", + control = "slider", + step = 1 +}) +node:output("tick", "number") +node:output("delta", "number") +node:state("accumulator", 0) +node:on_run(function(ctx) + local rate = ctx:field("rate") + if rate <= 0 then + return + end + local accumulator = ctx:state("accumulator") + rate / 20 + if accumulator >= 1 then + accumulator = accumulator - 1 + ctx:output("tick", ctx:tick()) + ctx:output("delta", 1 / rate) + end + ctx:set_state("accumulator", accumulator) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/state/counter.lua b/src/main/resources/computed/lua/nodes/state/counter.lua new file mode 100644 index 0000000..53ebf94 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/state/counter.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "computed:counter", "Counter") + +node:category("state") +node:input("increment", "number", { default = 0 }) +node:output("count", "number") +node:field("step", "number", { default = 1 }) +node:state("count", 0) +node:on_run(function(ctx) + local next = ctx:state("count") + ctx:input("increment") * ctx:field("step") + ctx:set_state("count", next) + ctx:output("count", next) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/state/delay.lua b/src/main/resources/computed/lua/nodes/state/delay.lua new file mode 100644 index 0000000..934cbd0 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/state/delay.lua @@ -0,0 +1,34 @@ +local node = computed.node(1, "computed:delay", "Delay") + +node:category("state") +node:execution("tick") +node:input("value", "number", { default = 0 }) +node:field("delay", "number", { + default = 1, + min = 0, + max = 200, + label = "Delay (ticks)", + control = "slider", + step = 1 +}) +node:output("delayed", "number") +node:state("values", {}) +node:on_run(function(ctx) + local delay = math.max(0, math.floor(ctx:field("delay"))) + if delay == 0 then + ctx:output("delayed", ctx:input("value")) + ctx:set_state("values", {}) + return + end + local values = ctx:state("values") + values[#values + 1] = ctx:input("value") + if #values > delay then + ctx:output("delayed", table.remove(values, 1)) + end + while #values > delay do + table.remove(values, 1) + end + ctx:set_state("values", values) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/state/pass_every_n.lua b/src/main/resources/computed/lua/nodes/state/pass_every_n.lua new file mode 100644 index 0000000..2a4258d --- /dev/null +++ b/src/main/resources/computed/lua/nodes/state/pass_every_n.lua @@ -0,0 +1,24 @@ +local node = computed.node(1, "computed:pass_every_n", "Pass Every N") + +node:category("state") +node:input("trigger", "boolean", { default = false }) +node:field("count", "number", { default = 2, min = 1 }) +node:output("pulse", "boolean") +node:state("previous", false) +node:state("seen", 0) +node:on_run(function(ctx) + local active = ctx:input("trigger") + local rising = active and not ctx:state("previous") + local seen = ctx:state("seen") + local pulse = false + if rising then + seen = seen + 1 + local count = math.max(1, math.floor(ctx:field("count"))) + pulse = seen % count == 0 + end + ctx:set_state("previous", active) + ctx:set_state("seen", seen) + ctx:output("pulse", pulse) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/state/sample_hold.lua b/src/main/resources/computed/lua/nodes/state/sample_hold.lua new file mode 100644 index 0000000..bcf1fec --- /dev/null +++ b/src/main/resources/computed/lua/nodes/state/sample_hold.lua @@ -0,0 +1,20 @@ +local node = computed.node(1, "computed:sample_hold", "Sample and Hold") + +node:category("state") +node:input("value", "number", { default = 0 }) +node:input("clock", "boolean", { default = false }) +node:output("held", "number") +node:state("value", 0) +node:state("previous_clock", false) +node:on_run(function(ctx) + local clock = ctx:input("clock") + local held = ctx:state("value") + if clock and not ctx:state("previous_clock") then + held = ctx:input("value") + end + ctx:set_state("value", held) + ctx:set_state("previous_clock", clock) + ctx:output("held", held) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/templates/binary.lua b/src/main/resources/computed/lua/nodes/templates/binary.lua new file mode 100644 index 0000000..533c6da --- /dev/null +++ b/src/main/resources/computed/lua/nodes/templates/binary.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "@ID@", "@TITLE@") + +node:category("@CATEGORY@") +node:input("a", "number", { default = 0 }) +node:input("b", "number", { default = 0 }) +node:output("result", "number") +node:on_run(function(ctx) + local a = ctx:input("a") + local b = ctx:input("b") + ctx:output("result", @EXPRESSION@) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/templates/comparison.lua b/src/main/resources/computed/lua/nodes/templates/comparison.lua new file mode 100644 index 0000000..4dfd244 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/templates/comparison.lua @@ -0,0 +1,13 @@ +local node = computed.node(1, "@ID@", "@TITLE@") + +node:category("logic") +node:input("a", "number", { default = 0 }) +node:input("b", "number", { default = 0 }) +node:output("result", "boolean") +node:on_run(function(ctx) + local a = ctx:input("a") + local b = ctx:input("b") + ctx:output("result", @EXPRESSION@) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/templates/unary.lua b/src/main/resources/computed/lua/nodes/templates/unary.lua new file mode 100644 index 0000000..8189a15 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/templates/unary.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "@ID@", "@TITLE@") + +node:category("@CATEGORY@") +node:input("value", "number", { default = 0 }) +node:output("result", "number") +node:on_run(function(ctx) + local value = ctx:input("value") + ctx:output("result", @EXPRESSION@) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/text/concatenate.lua b/src/main/resources/computed/lua/nodes/text/concatenate.lua new file mode 100644 index 0000000..5f1c5e2 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/text/concatenate.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "computed:concatenate_strings", "Concatenate") + +node:category("text") +node:input("a", "string", { default = "" }) +node:input("b", "string", { default = "" }) +node:output("text", "string") +node:on_run(function(ctx) + ctx:output("text", ctx:input("a") .. ctx:input("b")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/text/source.lua b/src/main/resources/computed/lua/nodes/text/source.lua new file mode 100644 index 0000000..64af7b2 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/text/source.lua @@ -0,0 +1,11 @@ +local node = computed.node(1, "computed:text_source", "Text") + +node:category("text") +node:style("source") +node:field("text", "text", { default = "" }) +node:output("text", "string") +node:on_run(function(ctx) + ctx:output("text", ctx:field("text")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/widgets/button.lua b/src/main/resources/computed/lua/nodes/widgets/button.lua new file mode 100644 index 0000000..af36ada --- /dev/null +++ b/src/main/resources/computed/lua/nodes/widgets/button.lua @@ -0,0 +1,63 @@ +local node = computed.node(1, "computed:button_widget", "Button Widget") + +node:category("widgets") +node:input("label", "string", { default = "Button" }) +node:input("color", "number", { default = 4294967295 }) +node:field("layout_mode", "choice", { + default = "line", + label = "Layout", + choices = { "line", "manual" } +}) +node:field("line", "number", { + default = 1, min = 1, step = 1, label = "Line", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("span", "number", { + default = 1, min = 1, step = 1, label = "Line Span", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("fit", "choice", { + default = "auto", + label = "Fit", + choices = { "auto", "fill" }, + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("x", "number", { + default = 0, label = "X", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("y", "number", { + default = 0, label = "Y", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("width", "number", { + default = 64, min = 1, label = "Width", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("height", "number", { + default = 16, min = 1, label = "Height", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:output("widget", "widget") +node:output("clicked", "boolean") +node:on_run(function(ctx) + local widget = ctx:endpoint("computed:widget"):call( + "button", + ctx:input("label"), + ctx:input("color")) + widget.x = math.floor(ctx:field("x")) + widget.y = math.floor(ctx:field("y")) + widget.width = math.max(1, math.floor(ctx:field("width"))) + widget.height = math.max(1, math.floor(ctx:field("height"))) + widget.layout_mode = ctx:field("layout_mode") + widget.line = math.max(1, math.floor(ctx:field("line"))) + widget.span = math.max(1, math.floor(ctx:field("span"))) + widget.fit = ctx:field("fit") + ctx:output("widget", widget) + ctx:output("clicked", false) +end) +node:on_event("input", function(ctx) + ctx:output("clicked", true) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/widgets/clock.lua b/src/main/resources/computed/lua/nodes/widgets/clock.lua new file mode 100644 index 0000000..d8a27b6 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/widgets/clock.lua @@ -0,0 +1,64 @@ +local node = computed.node(1, "computed:clock_widget", "Clock Widget") + +node:category("widgets") +node:input("color", "number", { default = 4294967295 }) +node:field("layout_mode", "choice", { + default = "line", + label = "Layout", + choices = { "line", "manual" } +}) +node:field("line", "number", { + default = 1, min = 1, step = 1, label = "Line", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("span", "number", { + default = 1, min = 1, step = 1, label = "Line Span", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("fit", "choice", { + default = "auto", + label = "Fit", + choices = { "auto", "fill" }, + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("x", "number", { + default = 0, label = "X", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("y", "number", { + default = 0, label = "Y", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("width", "number", { + default = 60, min = 1, label = "Width", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("height", "number", { + default = 12, min = 1, label = "Height", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("show_seconds", "boolean", { default = true }) +node:field("alignment", "choice", { + default = "center", + label = "Alignment", + choices = { "left", "center", "right" } +}) +node:output("widget", "widget") +node:on_run(function(ctx) + local widget = ctx:endpoint("computed:widget"):call( + "clock", + ctx:input("color"), + ctx:field("show_seconds")) + widget.x = math.floor(ctx:field("x")) + widget.y = math.floor(ctx:field("y")) + widget.width = math.max(1, math.floor(ctx:field("width"))) + widget.height = math.max(1, math.floor(ctx:field("height"))) + widget.alignment = ctx:field("alignment") + widget.layout_mode = ctx:field("layout_mode") + widget.line = math.max(1, math.floor(ctx:field("line"))) + widget.span = math.max(1, math.floor(ctx:field("span"))) + widget.fit = ctx:field("fit") + ctx:output("widget", widget) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/widgets/monitor.lua b/src/main/resources/computed/lua/nodes/widgets/monitor.lua new file mode 100644 index 0000000..aac82a2 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/widgets/monitor.lua @@ -0,0 +1,26 @@ +local node = computed.node(1, "computed:peripheral", "Monitor") + +node:category("widgets") +node:style("sink") +node:execution("tick") +node:field("face", "direction", { default = "front" }) +node:input("widget_1", "widget", { required = false }) +node:input("widget_2", "widget", { required = false }) +node:input("widget_3", "widget", { required = false }) +node:input("widget_4", "widget", { required = false }) +node:input("widget_5", "widget", { required = false }) +node:input("widget_6", "widget", { required = false }) +node:input("widget_7", "widget", { required = false }) +node:input("widget_8", "widget", { required = false }) +node:on_run(function(ctx) + local widgets = {} + for index = 1, 8 do + local widget = ctx:input("widget_" .. index) + if widget ~= nil then + widgets[#widgets + 1] = widget + end + end + ctx:endpoint("computed:monitor", ctx:field("face")):call("show", widgets) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/widgets/progress.lua b/src/main/resources/computed/lua/nodes/widgets/progress.lua new file mode 100644 index 0000000..92da533 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/widgets/progress.lua @@ -0,0 +1,62 @@ +local node = computed.node(1, "computed:progress_bar_widget", "Progress Bar Widget") + +node:category("widgets") +node:input("value", "number", { default = 0 }) +node:input("maximum", "number", { default = 1 }) +node:input("color", "number", { default = 4294967295 }) +node:field("layout_mode", "choice", { + default = "line", + label = "Layout", + choices = { "line", "manual" } +}) +node:field("line", "number", { + default = 1, min = 1, step = 1, label = "Line", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("span", "number", { + default = 1, min = 1, step = 1, label = "Line Span", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("fit", "choice", { + default = "auto", + label = "Fit", + choices = { "auto", "fill" }, + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("x", "number", { + default = 0, label = "X", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("y", "number", { + default = 0, label = "Y", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("width", "number", { + default = 64, min = 1, label = "Width", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("height", "number", { + default = 12, min = 1, label = "Height", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("segments", "number", { default = 0, min = 0 }) +node:output("widget", "widget") +node:on_run(function(ctx) + local widget = ctx:endpoint("computed:widget"):call( + "progress", + ctx:input("value"), + ctx:input("maximum"), + ctx:input("color"), + ctx:field("segments")) + widget.x = math.floor(ctx:field("x")) + widget.y = math.floor(ctx:field("y")) + widget.width = math.max(1, math.floor(ctx:field("width"))) + widget.height = math.max(1, math.floor(ctx:field("height"))) + widget.layout_mode = ctx:field("layout_mode") + widget.line = math.max(1, math.floor(ctx:field("line"))) + widget.span = math.max(1, math.floor(ctx:field("span"))) + widget.fit = ctx:field("fit") + ctx:output("widget", widget) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/widgets/slider.lua b/src/main/resources/computed/lua/nodes/widgets/slider.lua new file mode 100644 index 0000000..135a7b5 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/widgets/slider.lua @@ -0,0 +1,72 @@ +local node = computed.node(1, "computed:slider_widget", "Slider Widget") + +node:category("widgets") +node:input("minimum", "number", { default = 0 }) +node:input("maximum", "number", { default = 1 }) +node:input("color", "number", { default = 4294967295 }) +node:field("layout_mode", "choice", { + default = "line", + label = "Layout", + choices = { "line", "manual" } +}) +node:field("line", "number", { + default = 1, min = 1, step = 1, label = "Line", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("span", "number", { + default = 1, min = 1, step = 1, label = "Line Span", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("fit", "choice", { + default = "auto", + label = "Fit", + choices = { "auto", "fill" }, + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("x", "number", { + default = 0, label = "X", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("y", "number", { + default = 0, label = "Y", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("width", "number", { + default = 64, min = 1, label = "Width", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("height", "number", { + default = 16, min = 1, label = "Height", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("step", "number", { default = 0.01, min = 0 }) +node:output("widget", "widget") +node:output("value", "number") +node:state("value", 0) +local function render(ctx) + local value = ctx:state("value") + local widget = ctx:endpoint("computed:widget"):call( + "slider", + value, + ctx:input("minimum"), + ctx:input("maximum"), + ctx:input("color"), + ctx:field("step")) + widget.x = math.floor(ctx:field("x")) + widget.y = math.floor(ctx:field("y")) + widget.width = math.max(1, math.floor(ctx:field("width"))) + widget.height = math.max(1, math.floor(ctx:field("height"))) + widget.layout_mode = ctx:field("layout_mode") + widget.line = math.max(1, math.floor(ctx:field("line"))) + widget.span = math.max(1, math.floor(ctx:field("span"))) + widget.fit = ctx:field("fit") + ctx:output("widget", widget) + ctx:output("value", value) +end +node:on_run(render) +node:on_event("input", function(ctx, value) + ctx:set_state("value", value) + render(ctx) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/widgets/text.lua b/src/main/resources/computed/lua/nodes/widgets/text.lua new file mode 100644 index 0000000..f4aa15e --- /dev/null +++ b/src/main/resources/computed/lua/nodes/widgets/text.lua @@ -0,0 +1,61 @@ +local node = computed.node(1, "computed:text_widget", "Text Widget") + +node:category("widgets") +node:input("text", "string", { default = "" }) +node:field("layout_mode", "choice", { + default = "line", + label = "Layout", + choices = { "line", "manual" } +}) +node:field("line", "number", { + default = 1, min = 1, step = 1, label = "Line", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("span", "number", { + default = 1, min = 1, step = 1, label = "Line Span", + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("fit", "choice", { + default = "auto", + label = "Fit", + choices = { "auto", "fill" }, + visible_when = { field = "layout_mode", equals = "line" } +}) +node:field("x", "number", { + default = 0, label = "X", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("y", "number", { + default = 0, label = "Y", + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("width", "number", { + default = 64, min = 1, label = "Width", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("height", "number", { + default = 12, min = 1, label = "Height", step = 1, + visible_when = { field = "layout_mode", equals = "manual" } +}) +node:field("alignment", "choice", { + default = "center", + label = "Alignment", + choices = { "left", "center", "right" } +}) +node:output("widget", "widget") +node:on_run(function(ctx) + local widgets = ctx:endpoint("computed:widget") + local widget = widgets:call("text", ctx:input("text")) + widget.x = math.floor(ctx:field("x")) + widget.y = math.floor(ctx:field("y")) + widget.width = math.max(1, math.floor(ctx:field("width"))) + widget.height = math.max(1, math.floor(ctx:field("height"))) + widget.alignment = ctx:field("alignment") + widget.layout_mode = ctx:field("layout_mode") + widget.line = math.max(1, math.floor(ctx:field("line"))) + widget.span = math.max(1, math.floor(ctx:field("span"))) + widget.fit = ctx:field("fit") + ctx:output("widget", widget) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/world/comparator.lua b/src/main/resources/computed/lua/nodes/world/comparator.lua new file mode 100644 index 0000000..7fe3499 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/world/comparator.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "computed:comparator_read", "Comparator Read") + +node:category("world") +node:style("source") +node:execution("tick") +node:field("face", "direction", { default = "front" }) +node:output("level", "number") +node:on_run(function(ctx) + ctx:output( + "level", + ctx:endpoint("computed:redstone"):call("comparator", ctx:field("face"))) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/world/location.lua b/src/main/resources/computed/lua/nodes/world/location.lua new file mode 100644 index 0000000..7250e9a --- /dev/null +++ b/src/main/resources/computed/lua/nodes/world/location.lua @@ -0,0 +1,16 @@ +local node = computed.node(1, "computed:block_location", "Block Location") + +node:category("world") +node:style("source") +node:execution("tick") +node:output("x", "number") +node:output("y", "number") +node:output("z", "number") +node:on_run(function(ctx) + local x, y, z = ctx:endpoint("computed:world"):call("position") + ctx:output("x", x) + ctx:output("y", y) + ctx:output("z", z) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/world/presence.lua b/src/main/resources/computed/lua/nodes/world/presence.lua new file mode 100644 index 0000000..8c17674 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/world/presence.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "computed:block_presence", "Block Presence") + +node:category("world") +node:style("source") +node:execution("tick") +node:field("face", "direction", { default = "front" }) +node:output("present", "boolean") +node:on_run(function(ctx) + ctx:output( + "present", + ctx:endpoint("computed:world"):call("block_present", ctx:field("face"))) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/world/redstone_input.lua b/src/main/resources/computed/lua/nodes/world/redstone_input.lua new file mode 100644 index 0000000..b7895d5 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/world/redstone_input.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "computed:redstone_input", "Redstone Input") + +node:category("world") +node:style("source") +node:execution("tick") +node:field("face", "direction", { default = "front" }) +node:output("level", "number") +node:on_run(function(ctx) + ctx:output( + "level", + ctx:endpoint("computed:redstone"):call("input", ctx:field("face"))) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/world/redstone_output.lua b/src/main/resources/computed/lua/nodes/world/redstone_output.lua new file mode 100644 index 0000000..3ab72ee --- /dev/null +++ b/src/main/resources/computed/lua/nodes/world/redstone_output.lua @@ -0,0 +1,14 @@ +local node = computed.node(1, "computed:redstone_emitter", "Redstone Output") + +node:category("io") +node:style("sink") +node:input("level", "number", { default = 0 }) +node:field("face", "direction", { default = "front" }) +node:on_run(function(ctx) + ctx:endpoint("computed:redstone"):call( + "output", + ctx:field("face"), + ctx:input("level")) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/world/rotation.lua b/src/main/resources/computed/lua/nodes/world/rotation.lua new file mode 100644 index 0000000..87e4110 --- /dev/null +++ b/src/main/resources/computed/lua/nodes/world/rotation.lua @@ -0,0 +1,16 @@ +local node = computed.node(1, "computed:block_rotation", "Block Rotation") + +node:category("world") +node:style("source") +node:execution("tick") +node:output("yaw", "number") +node:output("pitch", "number") +node:output("roll", "number") +node:on_run(function(ctx) + local yaw, pitch, roll = ctx:endpoint("computed:world"):call("rotation") + ctx:output("yaw", yaw) + ctx:output("pitch", pitch) + ctx:output("roll", roll) +end) + +return node diff --git a/src/main/resources/computed/lua/nodes/world/time.lua b/src/main/resources/computed/lua/nodes/world/time.lua new file mode 100644 index 0000000..e69a1ee --- /dev/null +++ b/src/main/resources/computed/lua/nodes/world/time.lua @@ -0,0 +1,12 @@ +local node = computed.node(1, "computed:world_time", "World Time") + +node:category("world") +node:style("source") +node:output("time", "number") +node:execution("tick") +node:on_run(function(ctx) + local world = ctx:endpoint("computed:world") + ctx:output("time", world:call("time")) +end) + +return node diff --git a/src/main/templates/META-INF/neoforge.mods.toml b/src/main/templates/META-INF/neoforge.mods.toml index 3d158b3..81916b5 100644 --- a/src/main/templates/META-INF/neoforge.mods.toml +++ b/src/main/templates/META-INF/neoforge.mods.toml @@ -95,6 +95,13 @@ Example mod description. ordering="NONE" side="BOTH" +[[dependencies.${mod_id}]] + modId="computercraft" + type="optional" + versionRange="[1.120.0,)" + ordering="AFTER" + side="BOTH" + [[dependencies.${mod_id}]] modId="sablecompanion" type="optional" diff --git a/src/test/java/dev/propulsionteam/computed/api/node/ComputedNodeApiRegistryTest.java b/src/test/java/dev/propulsionteam/computed/api/node/ComputedNodeApiRegistryTest.java deleted file mode 100644 index 21188f8..0000000 --- a/src/test/java/dev/propulsionteam/computed/api/node/ComputedNodeApiRegistryTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.mojang.serialization.Codec; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** All irreversible common-registry assertions intentionally live in one test. */ -class ComputedNodeApiRegistryTest { - private RegistryIsolation.CommonSnapshot registrySnapshot; - - @BeforeEach - void isolateRegistry() throws ReflectiveOperationException { - registrySnapshot = RegistryIsolation.snapshotCommon(); - registrySnapshot.makeMutable(); - } - - @AfterEach - void restoreRegistry() throws ReflectiveOperationException { - registrySnapshot.restore(); - } - - @Test - void registryRejectsDuplicatesAndBecomesImmutableAfterExplicitFreeze() { - ResourceLocation categoryId = - ResourceLocation.fromNamespaceAndPath("computed_api_test", "registry_category"); - ResourceLocation nodeId = - ResourceLocation.fromNamespaceAndPath("computed_api_test", "registry_node"); - NodeCategory category = ComputedNodeApi.registerCategory( - categoryId, Component.literal("Test category"), ComputedNodeApi.ROOT_CATEGORY); - NodeType type = nodeType(nodeId, categoryId); - - assertSame(type, ComputedNodeApi.register(type)); - assertSame(type, ComputedNodeApi.requireNodeType(nodeId)); - assertEquals(category, ComputedNodeApi.category(categoryId).orElseThrow()); - assertThrows(UnsupportedOperationException.class, () -> ComputedNodeApi.nodeTypes().clear()); - - IllegalStateException duplicateType = - assertThrows(IllegalStateException.class, () -> ComputedNodeApi.register(type)); - assertTrue(duplicateType.getMessage().contains(nodeId.toString())); - IllegalStateException duplicateCategory = assertThrows( - IllegalStateException.class, - () -> ComputedNodeApi.registerCategory( - categoryId, Component.literal("Duplicate"), ComputedNodeApi.ROOT_CATEGORY)); - assertTrue(duplicateCategory.getMessage().contains(categoryId.toString())); - - ComputedNodeApi.freeze(); - ComputedNodeApi.freeze(); - assertTrue(ComputedNodeApi.isFrozen()); - IllegalStateException frozenType = assertThrows( - IllegalStateException.class, - () -> ComputedNodeApi.register(nodeType( - ResourceLocation.fromNamespaceAndPath("computed_api_test", "too_late"), categoryId))); - assertTrue(frozenType.getMessage().contains("frozen")); - IllegalStateException frozenCategory = assertThrows( - IllegalStateException.class, - () -> ComputedNodeApi.registerCategory( - ResourceLocation.fromNamespaceAndPath("computed_api_test", "too_late_category"), - Component.literal("Too late"), - ComputedNodeApi.ROOT_CATEGORY)); - assertTrue(frozenCategory.getMessage().contains("frozen")); - } - - private static NodeType nodeType(ResourceLocation id, ResourceLocation category) { - return NodeType.builder(id) - .title(Component.literal("Registry test node")) - .category(category) - .schema(NodeSchema.empty()) - .stateCodec(Codec.INT) - .defaultState(0) - .evaluator((prior, context) -> prior) - .build(); - } -} diff --git a/src/test/java/dev/propulsionteam/computed/api/node/ComputedNodeClientApiRegistryTest.java b/src/test/java/dev/propulsionteam/computed/api/node/ComputedNodeClientApiRegistryTest.java deleted file mode 100644 index 18efc26..0000000 --- a/src/test/java/dev/propulsionteam/computed/api/node/ComputedNodeClientApiRegistryTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.propulsionteam.computed.api.node.client.ComputedNodeClientApi; -import dev.propulsionteam.computed.api.node.client.NodePresentation; -import net.minecraft.resources.ResourceLocation; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** All irreversible client-registry assertions intentionally live in one test. */ -class ComputedNodeClientApiRegistryTest { - private RegistryIsolation.ClientSnapshot registrySnapshot; - - @BeforeEach - void isolateRegistry() throws ReflectiveOperationException { - registrySnapshot = RegistryIsolation.snapshotClient(); - registrySnapshot.makeMutable(); - } - - @AfterEach - void restoreRegistry() throws ReflectiveOperationException { - registrySnapshot.restore(); - } - - @Test - void presentationRegistryRejectsDuplicatesAndFreezes() { - ResourceLocation nodeId = - ResourceLocation.fromNamespaceAndPath("computed_api_test", "presented_node"); - NodePresentation presentation = ignored -> {}; - - assertSame(presentation, ComputedNodeClientApi.registerPresentation(nodeId, presentation)); - assertSame(presentation, ComputedNodeClientApi.presentation(nodeId).orElseThrow()); - assertThrows(UnsupportedOperationException.class, () -> ComputedNodeClientApi.presentations().clear()); - - IllegalStateException duplicate = assertThrows( - IllegalStateException.class, - () -> ComputedNodeClientApi.registerPresentation(nodeId, ignored -> {})); - assertTrue(duplicate.getMessage().contains(nodeId.toString())); - - ComputedNodeClientApi.freeze(); - ComputedNodeClientApi.freeze(); - assertTrue(ComputedNodeClientApi.isFrozen()); - IllegalStateException frozen = assertThrows( - IllegalStateException.class, - () -> ComputedNodeClientApi.registerPresentation( - ResourceLocation.fromNamespaceAndPath("computed_api_test", "too_late_presentation"), - ignored -> {})); - assertTrue(frozen.getMessage().contains("frozen")); - } -} diff --git a/src/test/java/dev/propulsionteam/computed/api/node/NodeExecutionApiTest.java b/src/test/java/dev/propulsionteam/computed/api/node/NodeExecutionApiTest.java deleted file mode 100644 index 773fde4..0000000 --- a/src/test/java/dev/propulsionteam/computed/api/node/NodeExecutionApiTest.java +++ /dev/null @@ -1,136 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertSame; - -import com.mojang.serialization.Codec; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import net.minecraft.core.BlockPos; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.server.level.ServerLevel; -import org.junit.jupiter.api.Test; - -class NodeExecutionApiTest { - private static final PortKey DELTA = PortKey.of("delta", PortType.NUMBER); - private static final PortKey TOTAL = PortKey.of("total", PortType.NUMBER); - private static final NodeProperty SCALE = NodeProperty.number("scale", Component.literal("Scale"), 2.0D); - - @Test - void evaluatorReadsTypedInputsAndPriorStateThenReturnsNextState() throws Exception { - Codec stateCodec = Codec.DOUBLE.xmap(AccumulatorState::new, AccumulatorState::total); - NodeType type = NodeType.builder( - ResourceLocation.fromNamespaceAndPath("computed_api_test", "accumulator")) - .title(Component.literal("Accumulator")) - .schema(NodeSchema.builder() - .input(DELTA, Component.literal("Delta")) - .output(TOTAL, Component.literal("Total")) - .build()) - .property(SCALE) - .stateCodec(stateCodec) - .defaultState(new AccumulatorState(0.0D)) - .stateBoundary(true) - .executionPolicy(ExecutionPolicy.EVERY_GRAPH_STEP) - .evaluator((priorState, context) -> { - double nextTotal = priorState.total() - + context.input(DELTA) * context.properties().get(SCALE); - context.output(TOTAL, nextTotal); - context.report(NodeDiagnostic.info("test.evaluated", Component.literal("Evaluated"))); - return new AccumulatorState(nextTotal); - }) - .build(); - FakeContext context = new FakeContext(type.defaultProperties()); - context.inputs.put(DELTA, 3.0D); - AccumulatorState prior = new AccumulatorState(4.0D); - - AccumulatorState next = type.evaluator().execute(prior, context); - - assertEquals(4.0D, prior.total(), "the immutable prior state remains unchanged"); - assertEquals(10.0D, next.total()); - assertEquals(10.0D, context.outputs.get(TOTAL)); - assertEquals(ExecutionPolicy.EVERY_GRAPH_STEP, type.executionPolicy()); - assertEquals(true, type.stateBoundary()); - assertEquals(0.0D, type.defaultState().total()); - assertSame(stateCodec, type.stateCodec()); - assertEquals("test.evaluated", context.reported.getFirst().code()); - assertFalse(context.runSideEffect(level -> { - throw new AssertionError("preview side effect must not run"); - })); - } - - private record AccumulatorState(double total) {} - - private static final class FakeContext implements NodeExecutionContext { - private final NodePropertyBag properties; - private final Map, Object> inputs = new HashMap<>(); - private final Map, Object> outputs = new HashMap<>(); - private final List reported = new ArrayList<>(); - - private FakeContext(NodePropertyBag properties) { - this.properties = properties; - } - - @Override - public NodePropertyBag properties() { - return properties; - } - - @Override - public T input(PortKey key) { - return key.type().castOrDefault(inputs.get(key)); - } - - @Override - public void output(PortKey key, T value) { - if (!key.type().accepts(value)) { - throw new IllegalArgumentException("Wrong output type for " + key); - } - outputs.put(key, value); - } - - @Override - public boolean isInputConnected(PortKey key) { - return inputs.containsKey(key); - } - - @Override - public long gameTick() { - return 42L; - } - - @Override - public long graphStep() { - return 7L; - } - - @Override - public boolean isPreview() { - return true; - } - - @Override - public Optional level() { - return Optional.empty(); - } - - @Override - public Optional origin() { - return Optional.of(BlockPos.ZERO); - } - - @Override - public boolean sideEffectsAllowed() { - return false; - } - - @Override - public DiagnosticSink diagnostics() { - return reported::add; - } - } -} diff --git a/src/test/java/dev/propulsionteam/computed/api/node/PortSchemaPropertyTest.java b/src/test/java/dev/propulsionteam/computed/api/node/PortSchemaPropertyTest.java deleted file mode 100644 index cc88219..0000000 --- a/src/test/java/dev/propulsionteam/computed/api/node/PortSchemaPropertyTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.mojang.serialization.Codec; -import java.util.List; -import net.minecraft.network.chat.Component; -import org.junit.jupiter.api.Test; - -class PortSchemaPropertyTest { - private static final PortKey AMOUNT = PortKey.of("amount", PortType.NUMBER); - private static final PortKey LABEL = PortKey.of("label", PortType.STRING); - private static final NodeProperty PORT_COUNT = NodeProperty.builder( - "port_count", Component.literal("Port count"), Integer.class, Codec.INT) - .defaultValue(2) - .validator(value -> value >= 0 && value <= 8, "must be between 0 and 8") - .build(); - - @Test - void builtInPortTypesHaveNeutralDefaultsAndRejectWrongValues() { - assertEquals(0.0D, PortType.NUMBER.defaultValue()); - assertEquals("", PortType.STRING.defaultValue()); - assertNull(PortType.WIDGET.defaultValue()); - - assertEquals(0.0D, PortType.NUMBER.castOrDefault("not a number")); - assertEquals("", PortType.STRING.castOrDefault(null)); - assertTrue(PortType.WIDGET.accepts(new Object())); - assertTrue(PortType.WIDGET.accepts(null)); - assertFalse(PortType.NUMBER.accepts(1)); - } - - @Test - void portKeysAndSchemasAreStableTypedAndOrdered() { - NodeSchema schema = NodeSchema.builder() - .input(AMOUNT, Component.literal("Amount")) - .output(LABEL, Component.literal("Label")) - .build(); - - assertEquals(List.of("amount", "label"), schema.ports().stream() - .map(port -> port.key().id()) - .toList()); - assertEquals(PortDirection.INPUT, schema.port(AMOUNT).orElseThrow().direction()); - assertEquals(PortDirection.OUTPUT, schema.port(LABEL).orElseThrow().direction()); - assertTrue(schema.port(PortKey.of("amount", PortType.STRING)).isEmpty()); - assertEquals(AMOUNT, PortKey.of("amount", PortType.NUMBER)); - - assertThrows(IllegalArgumentException.class, () -> PortKey.of("Display Name", PortType.STRING)); - assertThrows(IllegalArgumentException.class, () -> NodeSchema.builder() - .input(AMOUNT, Component.literal("First")) - .output(PortKey.of("amount", PortType.STRING), Component.literal("Duplicate")) - .build()); - } - - @Test - void propertyBagsAreTypedValidatedAndImmutable() { - NodeProperty gain = NodeProperty.builder( - "gain", Component.literal("Gain"), Double.class, Codec.DOUBLE) - .defaultValue(2.0D) - .validator(value -> Double.isFinite(value) && value >= 0.0D, "must be finite and non-negative") - .build(); - NodePropertyBag defaults = NodePropertyBag.defaults(List.of(gain)); - NodePropertyBag changed = defaults.with(gain, 3.5D); - - assertEquals(2.0D, defaults.get(gain)); - assertEquals(3.5D, changed.get(gain)); - assertNotSame(defaults, changed); - assertThrows(IllegalArgumentException.class, () -> defaults.with(gain, Double.NaN)); - assertThrows(UnsupportedOperationException.class, () -> changed.values().put("gain", 4.0D)); - - NodeProperty sameStableKey = NodeProperty.number("gain", Component.literal("Other"), 1.0D); - assertThrows(IllegalArgumentException.class, () -> defaults.get(sameStableKey)); - assertThrows( - IllegalArgumentException.class, - () -> NodePropertyBag.defaults(List.of(gain, sameStableKey))); - } - - @Test - void schemaFactoryCanDeriveStableDynamicPortsFromProperties() { - NodeSchemaFactory factory = properties -> { - NodeSchema.Builder schema = NodeSchema.builder(); - for (int index = 0; index < properties.get(PORT_COUNT); index++) { - schema.input( - PortKey.of("widget_" + index, PortType.WIDGET), - Component.literal("Widget " + (index + 1))); - } - return schema.build(); - }; - - NodePropertyBag defaults = NodePropertyBag.defaults(List.of(PORT_COUNT)); - NodeSchema twoPorts = factory.create(defaults); - NodeSchema threePorts = factory.create(defaults.with(PORT_COUNT, 3)); - - assertEquals(List.of("widget_0", "widget_1"), twoPorts.inputs().stream() - .map(port -> port.key().id()) - .toList()); - assertEquals(List.of("widget_0", "widget_1", "widget_2"), threePorts.inputs().stream() - .map(port -> port.key().id()) - .toList()); - } -} diff --git a/src/test/java/dev/propulsionteam/computed/api/node/RegistryIsolation.java b/src/test/java/dev/propulsionteam/computed/api/node/RegistryIsolation.java deleted file mode 100644 index 032fa9b..0000000 --- a/src/test/java/dev/propulsionteam/computed/api/node/RegistryIsolation.java +++ /dev/null @@ -1,86 +0,0 @@ -package dev.propulsionteam.computed.api.node; - -import dev.propulsionteam.computed.api.node.client.ComputedNodeClientApi; -import dev.propulsionteam.computed.api.node.client.NodePresentation; -import java.lang.reflect.Field; -import java.util.LinkedHashMap; -import java.util.Map; -import net.minecraft.resources.ResourceLocation; - -/** Saves and restores irreversible static registries so freeze tests cannot affect other test classes. */ -final class RegistryIsolation { - private RegistryIsolation() {} - - static CommonSnapshot snapshotCommon() throws ReflectiveOperationException { - Field typesField = field(ComputedNodeApi.class, "NODE_TYPES"); - Field categoriesField = field(ComputedNodeApi.class, "CATEGORIES"); - Field frozenField = field(ComputedNodeApi.class, "frozen"); - return new CommonSnapshot( - new LinkedHashMap<>(map(typesField)), - new LinkedHashMap<>(map(categoriesField)), - frozenField.getBoolean(null), - typesField, - categoriesField, - frozenField); - } - - static ClientSnapshot snapshotClient() throws ReflectiveOperationException { - Field presentationsField = field(ComputedNodeClientApi.class, "PRESENTATIONS"); - Field frozenField = field(ComputedNodeClientApi.class, "frozen"); - return new ClientSnapshot( - new LinkedHashMap<>(map(presentationsField)), - frozenField.getBoolean(null), - presentationsField, - frozenField); - } - - private static Field field(Class owner, String name) throws NoSuchFieldException { - Field field = owner.getDeclaredField(name); - field.setAccessible(true); - return field; - } - - @SuppressWarnings("unchecked") - private static Map map(Field field) throws IllegalAccessException { - return (Map) field.get(null); - } - - record CommonSnapshot( - Map> types, - Map categories, - boolean frozen, - Field typesField, - Field categoriesField, - Field frozenField) { - void makeMutable() throws IllegalAccessException { - frozenField.setBoolean(null, false); - } - - void restore() throws IllegalAccessException { - Map> liveTypes = map(typesField); - liveTypes.clear(); - liveTypes.putAll(types); - Map liveCategories = map(categoriesField); - liveCategories.clear(); - liveCategories.putAll(categories); - frozenField.setBoolean(null, frozen); - } - } - - record ClientSnapshot( - Map presentations, - boolean frozen, - Field presentationsField, - Field frozenField) { - void makeMutable() throws IllegalAccessException { - frozenField.setBoolean(null, false); - } - - void restore() throws IllegalAccessException { - Map livePresentations = map(presentationsField); - livePresentations.clear(); - livePresentations.putAll(presentations); - frozenField.setBoolean(null, frozen); - } - } -} diff --git a/src/test/java/dev/propulsionteam/computed/client/editor/canvas/InertialViewportTest.java b/src/test/java/dev/propulsionteam/computed/client/editor/canvas/InertialViewportTest.java new file mode 100644 index 0000000..208ca74 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/client/editor/canvas/InertialViewportTest.java @@ -0,0 +1,82 @@ +package dev.propulsionteam.computed.client.editor.canvas; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class InertialViewportTest { + @Test + void panRetainsAndThenDecaysReleaseVelocity() { + InertialViewport viewport = new InertialViewport(); + viewport.beginPan(); + viewport.dragPan(24, -12, 1.0 / 60.0); + viewport.endPan(); + double releasedX = viewport.panX(); + double releasedY = viewport.panY(); + + viewport.advance(1.0 / 60.0, 800, 600); + assertTrue(viewport.panX() > releasedX); + assertTrue(viewport.panY() < releasedY); + + for (int index = 0; index < 300; index++) { + viewport.advance(1.0 / 60.0, 800, 600); + } + double settledX = viewport.panX(); + viewport.advance(1, 800, 600); + assertEquals(settledX, viewport.panX(), 0.001); + } + + @Test + void zoomKeepsTheGraphPointUnderTheCursor() { + InertialViewport viewport = new InertialViewport(); + double beforeX = viewport.graphX(640, 800); + double beforeY = viewport.graphY(180, 600); + + viewport.addZoomImpulse(0.5, 640, 180); + for (int index = 0; index < 120; index++) { + viewport.advance(1.0 / 120.0, 800, 600); + } + + assertEquals(beforeX, viewport.graphX(640, 800), 0.001); + assertEquals(beforeY, viewport.graphY(180, 600), 0.001); + assertTrue(viewport.zoom() > 1); + } + + @Test + void integrationIsStableAcrossFrameRatesAndClampsZoom() { + InertialViewport sixtyFps = new InertialViewport(); + InertialViewport oneTwentyFps = new InertialViewport(); + sixtyFps.addZoomImpulse(0.4, 400, 300); + oneTwentyFps.addZoomImpulse(0.4, 400, 300); + for (int index = 0; index < 60; index++) { + sixtyFps.advance(1.0 / 60.0, 800, 600); + } + for (int index = 0; index < 120; index++) { + oneTwentyFps.advance(1.0 / 120.0, 800, 600); + } + assertEquals(sixtyFps.zoom(), oneTwentyFps.zoom(), 0.01); + + sixtyFps.addZoomImpulse(100, 400, 300); + sixtyFps.advance(1, 800, 600); + assertEquals(InertialViewport.MAX_ZOOM, sixtyFps.zoom()); + sixtyFps.addZoomImpulse(-100, 400, 300); + sixtyFps.advance(1, 800, 600); + assertEquals(InertialViewport.MIN_ZOOM, sixtyFps.zoom()); + } + + @Test + void restoreCancelsAllMotion() { + InertialViewport viewport = new InertialViewport(); + viewport.beginPan(); + viewport.dragPan(20, 10, 1.0 / 60.0); + viewport.endPan(); + viewport.addZoomImpulse(0.4, 400, 300); + viewport.restore(12, -8, 1.5f); + viewport.advance(1, 800, 600); + + assertEquals(12, viewport.panX()); + assertEquals(-8, viewport.panY()); + assertEquals(1.5f, viewport.zoom()); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorGraphAdapterTest.java b/src/test/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorGraphAdapterTest.java new file mode 100644 index 0000000..3df3f08 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/client/editor/canvas/LuaEditorGraphAdapterTest.java @@ -0,0 +1,291 @@ +package dev.propulsionteam.computed.client.editor.canvas; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import dev.propulsionteam.computed.graph.ComputedGraph; +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.GraphConnection; +import dev.propulsionteam.computed.graph.GraphNode; +import dev.propulsionteam.computed.graph.GraphPoint; +import dev.propulsionteam.computed.graph.PortDirection; +import dev.propulsionteam.computed.graph.PortSnapshot; +import dev.propulsionteam.computed.lua.node.BundledLuaLibrary; +import dev.propulsionteam.computed.lua.node.ConnectionType; +import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class LuaEditorGraphAdapterTest { + @Test + void preservesStablePortsPositionsAndWireWaypointsAcrossEditorRoundTrip() { + var definitions = BundledLuaLibrary.load(); + UUID sourceId = uuid(1); + UUID targetId = uuid(2); + UUID connectionId = uuid(3); + GraphNode source = new GraphNode( + sourceId, + "computed:add", + definitions.get("computed:add").hash(), + 10, + 20, + List.of( + port("a", PortDirection.INPUT), + port("b", PortDirection.INPUT), + port("result", PortDirection.OUTPUT)), + Map.of()); + GraphNode target = new GraphNode( + targetId, + "computed:counter", + definitions.get("computed:counter").hash(), + 100, + 20, + List.of( + port("increment", PortDirection.INPUT), + port("count", PortDirection.OUTPUT)), + Map.of()); + GraphConnection connection = new GraphConnection( + connectionId, + sourceId, + "result", + targetId, + "increment", + List.of(new GraphPoint(40, 12), new GraphPoint(70, 25))); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(uuid(10), List.of(source, target), List.of(connection)), + Map.of(), + Map.of(), + null); + + var editorGraph = LuaEditorGraphAdapter.toEditorGraph(program); + editorGraph.getNode(sourceId).setPos(15, 25); + ComputedProgramV3 restored = LuaEditorGraphAdapter.fromEditorGraph(editorGraph, program, 1); + + assertEquals(15, restored.rootGraph().node(sourceId).orElseThrow().x()); + assertEquals(connectionId, restored.rootGraph().connections().getFirst().id()); + assertEquals("result", restored.rootGraph().connections().getFirst().sourcePort()); + assertEquals("increment", restored.rootGraph().connections().getFirst().targetPort()); + assertEquals(connection.waypoints(), restored.rootGraph().connections().getFirst().waypoints()); + } + + @Test + void persistsNewlyPlacedLuaNodes() { + ComputedProgramV3 program = ComputedProgramV3.empty(uuid(20)); + var editorGraph = LuaEditorGraphAdapter.toEditorGraph(program); + var placed = LuaEditorGraphAdapter.createEditorNode(program, "computed:math_add", 40, 60); + + assertNotNull(placed); + editorGraph.addNode(placed); + ComputedProgramV3 restored = LuaEditorGraphAdapter.fromEditorGraph(editorGraph, program, 1); + + assertEquals(1, restored.rootGraph().nodes().size()); + assertEquals("computed:math_add", restored.rootGraph().nodes().getFirst().definitionId()); + assertEquals(40, restored.rootGraph().nodes().getFirst().x()); + assertEquals(60, restored.rootGraph().nodes().getFirst().y()); + } + + @Test + void replacementRetainsOnlyStableCompatiblePorts() { + String originalSource = """ + local node = computed.node(1, "example:test", "Test") + node:input("keep", "number") + node:input("remove", "number") + node:output("out", "number") + node:on_run(function(ctx) ctx:output("out", ctx:input("keep")) end) + return node + """; + var original = dev.propulsionteam.computed.graph.LuaDefinitionSource.embedded( + 1, + "example:test", + originalSource); + GraphNode first = new GraphNode( + uuid(21), + original.id(), + original.hash(), + 0, + 0, + List.of( + port("keep", PortDirection.INPUT), + port("remove", PortDirection.INPUT), + port("out", PortDirection.OUTPUT)), + Map.of()); + GraphNode second = new GraphNode( + uuid(22), + original.id(), + original.hash(), + 100, + 0, + first.ports(), + Map.of()); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph( + uuid(23), + List.of(first, second), + List.of( + new GraphConnection( + uuid(24), + first.id(), + "out", + second.id(), + "keep", + List.of()), + new GraphConnection( + uuid(25), + first.id(), + "out", + second.id(), + "remove", + List.of()))), + Map.of(original.id(), original), + Map.of(), + null); + String replacementSource = """ + local node = computed.node(1, "example:test", "Test") + node:input("keep", "number") + node:output("out", "number") + node:on_run(function(ctx) ctx:output("out", ctx:input("keep")) end) + return node + """; + var replacement = dev.propulsionteam.computed.graph.LuaDefinitionSource.embedded( + 1, + "example:test", + replacementSource); + + ComputedProgramV3 updated = LuaEditorGraphAdapter.replaceDefinition(program, replacement); + + assertEquals(1, updated.rootGraph().connections().size()); + assertEquals("keep", updated.rootGraph().connections().getFirst().targetPort()); + assertEquals(replacement.hash(), updated.rootGraph().nodes().getFirst().definitionHash()); + } + + @Test + void editedFieldValuesRoundTripAndDuplicateIndependently() { + String source = """ + local node = computed.node(1, "example:controls", "Controls") + node:field("amount", "number", { + default = 2, + min = 0, + max = 10, + control = "slider", + step = 0.5 + }) + node:on_run(function(ctx) end) + return node + """; + var definition = dev.propulsionteam.computed.graph.LuaDefinitionSource.embedded( + 1, + "example:controls", + source); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(uuid(30), List.of(), List.of()), + Map.of(definition.id(), definition), + Map.of(), + null); + LuaEditorNode node = LuaEditorGraphAdapter.createEditorNode( + program, + definition.id(), + 10, + 20); + node.setFieldValue("amount", org.luaj.vm2.LuaValue.valueOf(7.4)); + var graph = LuaEditorGraphAdapter.toEditorGraph(program); + graph.addNode(node); + + ComputedProgramV3 restored = LuaEditorGraphAdapter.fromEditorGraph(graph, program, 1); + double amount = new LuaStateCodec() + .decode(restored.rootGraph().nodes().getFirst().fields().get("amount")) + .todouble(); + LuaEditorNode duplicate = LuaEditorGraphAdapter.duplicateEditorNode(node, 40, 50); + duplicate.setFieldValue("amount", org.luaj.vm2.LuaValue.valueOf(1)); + + assertEquals(7.5, amount); + assertEquals(7.5, node.fieldValue("amount").todouble()); + assertEquals(1, duplicate.fieldValue("amount").todouble()); + } + + @Test + void conditionalFieldsResizeWithTheirControllingChoiceAndKeepHiddenValues() { + String source = """ + local node = computed.node(1, "example:conditional", "Conditional") + node:field("layout", "choice", { + default = "line", + choices = { "line", "manual" } + }) + node:field("line", "number", { + default = 1, + visible_when = { field = "layout", equals = "line" } + }) + node:field("x", "number", { + default = 0, + visible_when = { field = "layout", equals = "manual" } + }) + node:field("y", "number", { + default = 0, + visible_when = { field = "layout", equals = "manual" } + }) + node:on_run(function(ctx) end) + return node + """; + var definition = dev.propulsionteam.computed.graph.LuaDefinitionSource.embedded( + 1, + "example:conditional", + source); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(uuid(35), List.of(), List.of()), + Map.of(definition.id(), definition), + Map.of(), + null); + LuaEditorNode node = LuaEditorGraphAdapter.createEditorNode(program, definition.id(), 0, 0); + int lineHeight = node.getHeight(); + node.setFieldValue("x", org.luaj.vm2.LuaValue.valueOf(24)); + + node.setFieldValue("layout", org.luaj.vm2.LuaValue.valueOf("manual")); + + assertEquals(lineHeight + LuaNodeFieldControl.ROW_HEIGHT, node.getHeight()); + assertEquals(24, node.fieldValue("x").toint()); + + node.setFieldValue("layout", org.luaj.vm2.LuaValue.valueOf("line")); + + assertEquals(lineHeight, node.getHeight()); + assertEquals(24, node.fieldValue("x").toint()); + } + + @Test + void creationAddsTheDefinitionAndFirstInstanceAtomically() { + String source = """ + local node = computed.node(1, "user:created", "Created") + node:field("value", "number", { default = 4 }) + node:output("value", "number") + node:on_run(function(ctx) ctx:output("value", ctx:field("value")) end) + return node + """; + var definition = dev.propulsionteam.computed.graph.LuaDefinitionSource.embedded( + 1, + "user:created", + source); + ComputedProgramV3 empty = ComputedProgramV3.empty(uuid(40)); + + ComputedProgramV3 created = + LuaEditorGraphAdapter.addDefinitionAndNode(empty, definition, 75, 90); + + assertEquals(definition, created.library().get("user:created")); + assertEquals(1, created.rootGraph().nodes().size()); + assertEquals("user:created", created.rootGraph().nodes().getFirst().definitionId()); + assertEquals(75, created.rootGraph().nodes().getFirst().x()); + assertEquals(90, created.rootGraph().nodes().getFirst().y()); + assertNotNull(created.rootGraph().nodes().getFirst().fields().get("value")); + } + + private static PortSnapshot port(String id, PortDirection direction) { + return new PortSnapshot(id, direction, ConnectionType.NUMBER, id); + } + + private static UUID uuid(long value) { + return new UUID(0, value); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/client/editor/explorer/NodeExplorerModelTest.java b/src/test/java/dev/propulsionteam/computed/client/editor/explorer/NodeExplorerModelTest.java new file mode 100644 index 0000000..2874b9f --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/client/editor/explorer/NodeExplorerModelTest.java @@ -0,0 +1,56 @@ +package dev.propulsionteam.computed.client.editor.explorer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class NodeExplorerModelTest { + @Test + void sortsFoldersBeforeNodesAndRestoresExpansionAfterSearch() { + NodeExplorerModel explorer = new NodeExplorerModel(List.of( + new ExplorerNode( + "computed:add", + "Add", + ExplorerNode.Ownership.BUNDLED, + List.of("math", "arithmetic"), + true, + ""), + new ExplorerNode( + "addon:kinetic", + "Kinetic Speed", + ExplorerNode.Ownership.INTEGRATION, + List.of("create", "kinetics"), + false, + "Create is not installed"), + new ExplorerNode( + "user:counter", + "Counter", + ExplorerNode.Ownership.USER, + List.of("state"), + true, + ""))); + explorer.setExpanded("bundled/math", true); + explorer.setExpanded("bundled/math/arithmetic", true); + + List initial = explorer.visibleRows(); + assertEquals(List.of("Bundled", "Integrations", "User Nodes"), initial.stream() + .filter(row -> row.depth() == 0) + .map(ExplorerRow::label) + .toList()); + + explorer.search("kinetic"); + List filtered = explorer.visibleRows(); + assertTrue(filtered.stream().anyMatch(row -> row.label().equals("Kinetic Speed"))); + ExplorerRow unavailable = filtered.stream() + .filter(row -> row.node() != null) + .findFirst() + .orElseThrow(); + assertFalse(unavailable.node().available()); + + explorer.search(""); + assertTrue(explorer.visibleRows().stream().anyMatch(row -> row.label().equals("Add"))); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSessionTest.java b/src/test/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSessionTest.java new file mode 100644 index 0000000..d7bd3c0 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/client/editor/lua/LuaEditorSessionTest.java @@ -0,0 +1,41 @@ +package dev.propulsionteam.computed.client.editor.lua; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.client.editor.preview.LuaLivePreview; +import org.junit.jupiter.api.Test; + +class LuaEditorSessionTest { + @Test + void debouncesCompilationAndKeepsTheLastValidPreviewStaleOnErrors() { + LuaEditorSession editor = new LuaEditorSession(); + String valid = """ + local node = computed.node(1, "example:preview", "Preview") + node:output("value", "number") + node:on_run(function(ctx) + ctx:output("value", 7) + end) + return node + """; + editor.sourceChanged(valid, 1000); + + assertFalse(editor.update(1249)); + assertTrue(editor.update(1250)); + assertNotNull(editor.snapshot().currentDefinition()); + assertFalse(editor.snapshot().stalePreview()); + + LuaLivePreview preview = new LuaLivePreview(valid); + assertTrue(preview.run().outputs().containsKey("value")); + assertTrue(preview.layout().width() > 0); + + editor.sourceChanged("local =", 2000); + assertTrue(editor.update(2250)); + assertNull(editor.snapshot().currentDefinition()); + assertNotNull(editor.snapshot().lastValidDefinition()); + assertTrue(editor.snapshot().stalePreview()); + assertFalse(editor.snapshot().diagnostics().isEmpty()); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/client/editor/lua/LuaNodeStarterTest.java b/src/test/java/dev/propulsionteam/computed/client/editor/lua/LuaNodeStarterTest.java new file mode 100644 index 0000000..02738f2 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/client/editor/lua/LuaNodeStarterTest.java @@ -0,0 +1,25 @@ +package dev.propulsionteam.computed.client.editor.lua; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.node.LuaDefinitionLoader; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import org.junit.jupiter.api.Test; + +class LuaNodeStarterTest { + @Test + void createsUniqueCompilableReusableDefinitions() { + LuaNodeStarter.Starter first = LuaNodeStarter.create(); + LuaNodeStarter.Starter second = LuaNodeStarter.create(); + var definition = new LuaDefinitionLoader().load( + new LuaSourceCompiler().compile(1, first.source()), + new LuaSandbox()); + + assertTrue(first.id().startsWith("user:node_")); + assertNotEquals(first.id(), second.id()); + assertTrue(first.source().contains(first.id())); + assertTrue(definition.id().equals(first.id())); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/client/editor/preview/LuaLivePreviewTest.java b/src/test/java/dev/propulsionteam/computed/client/editor/preview/LuaLivePreviewTest.java new file mode 100644 index 0000000..eb6cd9e --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/client/editor/preview/LuaLivePreviewTest.java @@ -0,0 +1,57 @@ +package dev.propulsionteam.computed.client.editor.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpoints; +import dev.propulsionteam.computed.lua.runtime.LuaNodeStatus; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaValue; + +class LuaLivePreviewTest { + @Test + void supportsSampleInputsEventsAndResettableState() { + LuaLivePreview preview = new LuaLivePreview(""" + local node = computed.node(1, "example:preview_state", "Preview State") + node:input("step", "number", { default = 1 }) + node:output("value", "number") + node:state("value", 0) + node:on_run(function(ctx) + local value = ctx:state("value") + ctx:input("step") + ctx:set_state("value", value) + ctx:output("value", value) + end) + node:on_event("reset", function(ctx, value) + ctx:set_state("value", value) + ctx:output("value", value) + end) + return node + """); + preview.setInput("step", LuaValue.valueOf(3)); + + assertEquals(3, preview.run().outputs().get("value").toint()); + assertEquals(6, preview.run().outputs().get("value").toint()); + assertEquals(2, preview.event("reset", LuaValue.valueOf(2)).outputs().get("value").toint()); + + preview.reset(); + preview.setInput("step", LuaValue.valueOf(3)); + assertEquals(3, preview.run().outputs().get("value").toint()); + } + + @Test + void blocksSideEffectingEndpointsWithoutCallingAProductionHost() { + BuiltinEndpoints.register(); + LuaLivePreview preview = new LuaLivePreview(""" + local node = computed.node(1, "example:preview_side_effect", "Preview Side Effect") + node:on_run(function(ctx) + ctx:endpoint("computed:command"):call("run", "say blocked") + end) + return node + """); + + var result = preview.run(); + + assertEquals(LuaNodeStatus.FAILED, result.status()); + assertFalse(result.diagnostics().isEmpty()); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/client/renderer/node/NodeRendererContractTest.java b/src/test/java/dev/propulsionteam/computed/client/renderer/node/NodeRendererContractTest.java new file mode 100644 index 0000000..19bab87 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/client/renderer/node/NodeRendererContractTest.java @@ -0,0 +1,77 @@ +package dev.propulsionteam.computed.client.renderer.node; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.node.LuaDefinitionLoader; +import dev.propulsionteam.computed.lua.node.NodeStyle; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class NodeRendererContractTest { + @Test + void mapsEverySemanticCategoryAndKeepsStatusColorsDistinct() { + assertEquals(NodePalette.FLOW, NodePalette.category("flow")); + assertEquals(NodePalette.LOGIC, NodePalette.category("logic")); + assertEquals(NodePalette.MATH, NodePalette.category("math")); + assertEquals(NodePalette.WORLD, NodePalette.category("world")); + assertEquals(NodePalette.STATE, NodePalette.category("state")); + assertEquals(NodePalette.TEXT, NodePalette.category("text")); + assertEquals(NodePalette.WIDGETS, NodePalette.category("widgets")); + assertEquals(NodePalette.IO, NodePalette.category("io")); + assertEquals(NodePalette.LUA, NodePalette.category("lua")); + assertEquals(NodePalette.INTEGRATION, NodePalette.category("integration/create")); + assertEquals(NodePalette.UTILITY, NodePalette.category("unknown")); + assertEquals(NodePalette.values().length, Arrays.stream(NodePalette.values()) + .map(NodePalette::frameArgb) + .distinct() + .count()); + assertNotEquals(NodePalette.SELECTION, NodePalette.ERROR); + assertNotEquals(NodePalette.ERROR, NodePalette.WARNING); + } + + @Test + void measuresEveryStyleAndAllFieldRowsWithStablePixelSpacing() { + for (NodeStyle style : NodeStyle.values()) { + String source = """ + local node = computed.node(1, "example:layout_%s", "Layout") + node:style("%s") + node:input("number", "number") + node:output("table", "table") + node:field("number", "number", { default = 1 }) + node:field("text", "text", { default = "x" }) + node:field("boolean", "boolean", { default = true }) + node:field("choice", "choice", { default = "a", choices = { "a", "b" } }) + node:field("color", "color", { default = 0 }) + node:field("direction", "direction", { default = "north" }) + node:field("item", "item", { default = "minecraft:stone" }) + node:on_run(function(ctx) end) + return node + """.formatted(style.name().toLowerCase(), style.name().toLowerCase()); + var definition = new LuaDefinitionLoader().load( + new LuaSourceCompiler().compile(1, source), + new LuaSandbox()); + NodeRenderLayout layout = NodeRenderLayout.measure(definition); + + assertTrue(layout.width() >= 96); + assertEquals(12, layout.socketSpacing()); + assertTrue(layout.panelHeight() >= 7 * 18); + if (style == NodeStyle.COMPACT) { + assertTrue(layout.sideRail()); + assertTrue(layout.width() >= 144); + } else { + assertFalse(layout.width() < 96); + } + } + } + + @Test + void keepsPinTextClearOfTheContentEdge() { + assertTrue(BedrockNodeRenderer.CONTENT_INSET >= 5); + assertTrue(BedrockNodeRenderer.PIN_LABEL_PADDING >= 5); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/content/monitors/widgets/MonitorWidgetLayoutTest.java b/src/test/java/dev/propulsionteam/computed/content/monitors/widgets/MonitorWidgetLayoutTest.java new file mode 100644 index 0000000..0c69b1e --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/content/monitors/widgets/MonitorWidgetLayoutTest.java @@ -0,0 +1,59 @@ +package dev.propulsionteam.computed.content.monitors.widgets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class MonitorWidgetLayoutTest { + @Test + void preservesRawWidgetCoordinates() { + TextWidget widget = new TextWidget( + UUID.randomUUID(), 5, 7, 20, 9, "raw", 0xFFFFFFFF, TextAlignment.LEFT); + + List resolved = MonitorWidgetLayout.resolve(List.of(widget), 128, 64); + + assertEquals(1, resolved.size()); + assertSame(widget, resolved.getFirst()); + } + + @Test + void resolvesLineManagedWidgetsAgainstMonitorDimensions() { + UUID id = UUID.randomUUID(); + LayoutManagedWidget managed = new LayoutManagedWidget( + new TextWidget(id, 0, 0, 1, 1, "line", 0xFFFFFFFF, TextAlignment.CENTER), + LayoutManagedWidget.LayoutMode.LINE, + 1, + 1, + LayoutManagedWidget.Fit.AUTO); + + List resolved = MonitorWidgetLayout.resolve(List.of(managed), 128, 64); + + assertEquals( + new TextWidget(id, 8, 8, 112, 20, "line", 0xFFFFFFFF, TextAlignment.CENTER), + resolved.getFirst()); + } + + @Test + void stretchesTheSameLineWidgetAcrossDifferentMonitorWidths() { + UUID id = UUID.randomUUID(); + LayoutManagedWidget managed = new LayoutManagedWidget( + new TextWidget(id, 27, 19, 3, 4, "responsive", 0xFFFFFFFF, TextAlignment.CENTER), + LayoutManagedWidget.LayoutMode.LINE, + 1, + 1, + LayoutManagedWidget.Fit.AUTO); + + TextWidget oneBlock = (TextWidget) MonitorWidgetLayout.resolve(List.of(managed), 64, 64).getFirst(); + TextWidget threeBlocks = (TextWidget) MonitorWidgetLayout.resolve(List.of(managed), 192, 64).getFirst(); + + assertEquals(8, oneBlock.x()); + assertEquals(48, oneBlock.w()); + assertEquals(8, threeBlocks.x()); + assertEquals(176, threeBlocks.w()); + assertEquals(oneBlock.y(), threeBlocks.y()); + assertEquals(oneBlock.h(), threeBlocks.h()); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/graph/GraphAnalyzerTest.java b/src/test/java/dev/propulsionteam/computed/graph/GraphAnalyzerTest.java new file mode 100644 index 0000000..9d4cf0b --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/graph/GraphAnalyzerTest.java @@ -0,0 +1,95 @@ +package dev.propulsionteam.computed.graph; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.node.ConnectionType; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class GraphAnalyzerTest { + @Test + void producesStableTopologicalOrderAndFindsCombinationalCycles() { + UUID first = uuid(1); + UUID second = uuid(2); + UUID third = uuid(3); + ComputedGraph acyclic = new ComputedGraph( + uuid(100), + List.of(node(third), node(first), node(second)), + List.of(connection(first, second), connection(second, third))); + + GraphAnalysisResult ordered = GraphAnalyzer.analyze(acyclic, ignored -> false); + GraphAnalysisResult repeated = GraphAnalyzer.analyze(acyclic, ignored -> false); + + assertEquals(List.of(first, second, third), ordered.executionOrder()); + assertEquals(ordered.executionOrder(), repeated.executionOrder()); + assertTrue(ordered.combinationalCycles().isEmpty()); + + ComputedGraph cyclic = new ComputedGraph( + uuid(101), + List.of(node(first), node(second)), + List.of(connection(first, second), connection(second, first))); + GraphAnalysisResult cycle = GraphAnalyzer.analyze(cyclic, ignored -> false); + + assertEquals(List.of(List.of(first, second)), cycle.combinationalCycles()); + assertTrue(cycle.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code().equals("combinational_cycle"))); + } + + @Test + void treatsStateBoundariesAsCycleBreaksAndValidatesPortTypes() { + UUID first = uuid(1); + UUID second = uuid(2); + ComputedGraph statefulCycle = new ComputedGraph( + uuid(102), + List.of(node(first), node(second)), + List.of(connection(first, second), connection(second, first))); + + GraphAnalysisResult result = GraphAnalyzer.analyze(statefulCycle, node -> node.id().equals(second)); + + assertTrue(result.combinationalCycles().isEmpty()); + assertEquals(List.of(second, first), result.executionOrder()); + + GraphNode booleanTarget = new GraphNode( + uuid(3), + "example:boolean", + "", + 0, + 0, + List.of( + new PortSnapshot("in", PortDirection.INPUT, ConnectionType.BOOLEAN, "In"), + new PortSnapshot("out", PortDirection.OUTPUT, ConnectionType.BOOLEAN, "Out")), + Map.of()); + ComputedGraph invalid = new ComputedGraph( + uuid(103), + List.of(node(first), booleanTarget), + List.of(new GraphConnection(uuid(300), first, "out", booleanTarget.id(), "in", List.of()))); + + assertTrue(GraphAnalyzer.analyze(invalid, ignored -> false) + .diagnostics() + .stream() + .anyMatch(diagnostic -> diagnostic.code().equals("incompatible_ports"))); + } + + private static GraphNode node(UUID id) { + return new GraphNode( + id, + "example:node", + "", + 0, + 0, + List.of( + new PortSnapshot("in", PortDirection.INPUT, ConnectionType.NUMBER, "In"), + new PortSnapshot("out", PortDirection.OUTPUT, ConnectionType.NUMBER, "Out")), + Map.of()); + } + + private static GraphConnection connection(UUID source, UUID target) { + return new GraphConnection(UUID.randomUUID(), source, "out", target, "in", List.of()); + } + + private static UUID uuid(long value) { + return new UUID(0, value); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/graph/LuaGraphSchedulerTest.java b/src/test/java/dev/propulsionteam/computed/graph/LuaGraphSchedulerTest.java new file mode 100644 index 0000000..f3efee8 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/graph/LuaGraphSchedulerTest.java @@ -0,0 +1,298 @@ +package dev.propulsionteam.computed.graph; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpointHost; +import dev.propulsionteam.computed.lua.endpoint.BuiltinWidget; +import dev.propulsionteam.computed.lua.node.BundledLuaLibrary; +import dev.propulsionteam.computed.lua.node.ConnectionType; +import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaValue; + +class LuaGraphSchedulerTest { + private final LuaStateCodec codec = new LuaStateCodec(); + + @Test + void executesBundledAndEmbeddedNodesInDeterministicDataflowOrder() { + LuaDefinitionSource sourceDefinition = + LuaDefinitionSource.embedded(1, "example:number", """ + local node = computed.node(1, "example:number", "Number") + node:category("utility") + node:style("source") + node:field("value", "number", { default = 0 }) + node:output("value", "number") + node:on_run(function(ctx) + ctx:output("value", ctx:field("value")) + end) + return node + """); + Map bundled = BundledLuaLibrary.load(); + UUID sourceId = uuid(1); + UUID addId = uuid(2); + UUID counterId = uuid(3); + GraphNode source = new GraphNode( + sourceId, + sourceDefinition.id(), + sourceDefinition.hash(), + 0, + 0, + List.of(port("value", PortDirection.OUTPUT, ConnectionType.NUMBER)), + Map.of("value", codec.encode(LuaValue.valueOf(3)))); + GraphNode add = new GraphNode( + addId, + "computed:add", + bundled.get("computed:add").hash(), + 80, + 0, + List.of( + port("a", PortDirection.INPUT, ConnectionType.NUMBER), + port("b", PortDirection.INPUT, ConnectionType.NUMBER), + port("result", PortDirection.OUTPUT, ConnectionType.NUMBER)), + Map.of()); + GraphNode counter = new GraphNode( + counterId, + "computed:counter", + bundled.get("computed:counter").hash(), + 160, + 0, + List.of( + port("increment", PortDirection.INPUT, ConnectionType.NUMBER), + port("count", PortDirection.OUTPUT, ConnectionType.NUMBER)), + Map.of("step", codec.encode(LuaValue.valueOf(2)))); + List connections = List.of( + edge(sourceId, "value", addId, "a"), + edge(sourceId, "value", addId, "b"), + edge(addId, "result", counterId, "increment")); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(uuid(100), List.of(counter, add, source), connections), + Map.of(sourceDefinition.id(), sourceDefinition), + Map.of(), + null); + + LuaGraphScheduler scheduler = new LuaGraphScheduler(program, uuid(200), null); + LuaGraphTickResult first = scheduler.tick(false); + LuaGraphTickResult second = scheduler.tick(false); + + assertTrue(first.diagnostics().isEmpty()); + assertEquals(3, first.graphSteps()); + assertEquals(3.0, first.outputs().get(sourceId).get("value").todouble()); + assertEquals(6.0, first.outputs().get(addId).get("result").todouble()); + assertEquals(12.0, first.outputs().get(counterId).get("count").todouble()); + assertEquals(12.0, second.outputs().get(counterId).get("count").todouble()); + + ComputedProgramV3 snapshot = scheduler.snapshot(9); + assertEquals(9, snapshot.revision()); + assertFalse(snapshot.persistentState().isEmpty()); + + LuaGraphScheduler restored = new LuaGraphScheduler(snapshot, uuid(201), null); + LuaGraphTickResult afterReload = restored.tick(false); + assertEquals(24.0, afterReload.outputs().get(counterId).get("count").todouble()); + } + + @Test + void usesPreviewFixturesAndProductionEndpointHosts() { + LuaDefinitionSource world = BundledLuaLibrary.load().get("computed:world_time"); + UUID nodeId = uuid(10); + GraphNode node = new GraphNode( + nodeId, + world.id(), + world.hash(), + 0, + 0, + List.of(port("time", PortDirection.OUTPUT, ConnectionType.NUMBER)), + Map.of()); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(uuid(101), List.of(node), List.of()), + Map.of(), + Map.of(), + null); + Host host = new Host(); + LuaGraphScheduler scheduler = new LuaGraphScheduler(program, uuid(202), host); + + assertEquals(6000.0, scheduler.tick(true).outputs().get(nodeId).get("time").todouble()); + assertEquals(18000.0, scheduler.tick(false).outputs().get(nodeId).get("time").todouble()); + } + + @Test + void sendsClockWidgetToMonitorEndpoint() { + Map bundled = BundledLuaLibrary.load(); + UUID colorId = uuid(10); + UUID clockId = uuid(11); + UUID monitorId = uuid(12); + GraphNode color = new GraphNode( + colorId, + "computed:color_source", + bundled.get("computed:color_source").hash(), + -100, + 0, + List.of(port("color", PortDirection.OUTPUT, ConnectionType.NUMBER)), + Map.of("color", codec.encode(LuaValue.valueOf(0xffffffffL)))); + GraphNode clock = new GraphNode( + clockId, + "computed:clock_widget", + bundled.get("computed:clock_widget").hash(), + 0, + 0, + List.of( + port("color", PortDirection.INPUT, ConnectionType.NUMBER), + port("widget", PortDirection.OUTPUT, ConnectionType.WIDGET)), + Map.of()); + GraphNode monitor = new GraphNode( + monitorId, + "computed:peripheral", + bundled.get("computed:peripheral").hash(), + 100, + 0, + List.of(port("widget_1", PortDirection.INPUT, ConnectionType.WIDGET)), + Map.of()); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph( + uuid(105), + List.of(monitor, clock, color), + List.of( + edge(colorId, "color", clockId, "color"), + edge(clockId, "widget", monitorId, "widget_1"))), + Map.of(), + Map.of(), + null); + Host host = new Host(); + + LuaGraphTickResult result = new LuaGraphScheduler(program, uuid(206), host).tick(false); + + assertTrue(result.diagnostics().isEmpty()); + assertEquals(List.of("front"), host.monitorTargets); + assertEquals(1, host.monitorWidgets.getFirst().size()); + assertEquals("clock", host.monitorWidgets.getFirst().getFirst().type()); + assertEquals(clockId, host.monitorWidgets.getFirst().getFirst().id()); + } + + @Test + void keepsMissingDefinitionsAsDiagnosedNonExecutableNodes() { + UUID nodeId = uuid(20); + GraphNode missing = new GraphNode( + nodeId, + "missing:addon_node", + "old-hash", + 0, + 0, + List.of(port("value", PortDirection.OUTPUT, ConnectionType.NUMBER)), + Map.of()); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(uuid(102), List.of(missing), List.of()), + Map.of(), + Map.of(), + null); + + LuaGraphTickResult result = new LuaGraphScheduler(program, uuid(203), null).tick(false); + + assertTrue(result.outputs().getOrDefault(nodeId, Map.of()).isEmpty()); + assertTrue(result.diagnostics().stream() + .anyMatch(diagnostic -> diagnostic.code().equals("missing_definition"))); + } + + @Test + void acceptsBundledDefinitionUpdatesButKeepsEmbeddedHashProtection() { + LuaDefinitionSource bundled = BundledLuaLibrary.load().get("computed:constant"); + UUID bundledId = uuid(22); + GraphNode bundledNode = new GraphNode( + bundledId, + bundled.id(), + "previous-bundled-hash", + 0, + 0, + List.of(port("value", PortDirection.OUTPUT, ConnectionType.NUMBER)), + Map.of()); + ComputedProgramV3 bundledProgram = new ComputedProgramV3( + 0, + new ComputedGraph(uuid(104), List.of(bundledNode), List.of()), + Map.of(), + Map.of(), + null); + + LuaGraphTickResult bundledResult = + new LuaGraphScheduler(bundledProgram, uuid(205), null).tick(false); + + assertEquals(10, bundledResult.outputs().get(bundledId).get("value").toint()); + assertFalse(bundledResult.diagnostics().stream() + .anyMatch(diagnostic -> diagnostic.code().equals("definition_hash_mismatch"))); + } + + @Test + void rejectsMaliciousEmbeddedSourceBeforeItCanBecomeExecutable() { + LuaDefinitionSource invalid = LuaDefinitionSource.embedded( + 1, + "example:malicious", + "return os.execute('anything')"); + UUID nodeId = uuid(21); + GraphNode node = new GraphNode( + nodeId, + invalid.id(), + invalid.hash(), + 0, + 0, + List.of(), + Map.of()); + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(uuid(103), List.of(node), List.of()), + Map.of(invalid.id(), invalid), + Map.of(), + null); + + LuaGraphTickResult result = new LuaGraphScheduler(program, uuid(204), null).tick(false); + + assertTrue(result.outputs().getOrDefault(nodeId, Map.of()).isEmpty()); + assertTrue(result.diagnostics().stream() + .anyMatch(diagnostic -> diagnostic.code().equals("definition_load_failed"))); + } + + private static PortSnapshot port(String id, PortDirection direction, ConnectionType type) { + return new PortSnapshot(id, direction, type, id); + } + + private static GraphConnection edge( + UUID source, + String sourcePort, + UUID target, + String targetPort) { + return new GraphConnection(UUID.randomUUID(), source, sourcePort, target, targetPort, List.of()); + } + + private static UUID uuid(long value) { + return new UUID(0, value); + } + + private static final class Host implements BuiltinEndpointHost { + private final List commands = new ArrayList<>(); + private final List monitorTargets = new ArrayList<>(); + private final List> monitorWidgets = new ArrayList<>(); + + @Override + public double worldTime() { + return 18000; + } + + @Override + public void runCommand(String command) { + commands.add(command); + } + + @Override + public void showWidgets(String target, List widgets) { + monitorTargets.add(target); + monitorWidgets.add(widgets); + } + } +} diff --git a/src/test/java/dev/propulsionteam/computed/graph/LuaSchedulerBenchmarkTest.java b/src/test/java/dev/propulsionteam/computed/graph/LuaSchedulerBenchmarkTest.java new file mode 100644 index 0000000..1408366 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/graph/LuaSchedulerBenchmarkTest.java @@ -0,0 +1,75 @@ +package dev.propulsionteam.computed.graph; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.node.ConnectionType; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("benchmark") +class LuaSchedulerBenchmarkTest { + private static final int NODE_COUNT = 500; + private static final int WARMUP_TICKS = 60; + private static final int SAMPLE_TICKS = 120; + private static final long MAX_P95_NANOS = 10_000_000; + + @Test + void fiveHundredActiveNodesStayWithinTheP95Budget() { + LuaDefinitionSource definition = LuaDefinitionSource.embedded(1, "benchmark:active", """ + local node = computed.node(1, "benchmark:active", "Active") + node:category("utility") + node:execution("tick") + node:output("value", "number") + node:on_run(function(ctx) + ctx:output("value", ctx:tick()) + end) + return node + """); + List nodes = new ArrayList<>(NODE_COUNT); + for (int index = 0; index < NODE_COUNT; index++) { + nodes.add(new GraphNode( + new UUID(1, index + 1L), + definition.id(), + definition.hash(), + index % 25 * 80, + index / 25 * 50, + List.of(new PortSnapshot( + "value", + PortDirection.OUTPUT, + ConnectionType.NUMBER, + "value")), + Map.of())); + } + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(new UUID(2, 1), nodes, List.of()), + Map.of(definition.id(), definition), + Map.of(), + null); + LuaGraphScheduler scheduler = new LuaGraphScheduler(program, new UUID(3, 1), null); + for (int tick = 0; tick < WARMUP_TICKS; tick++) { + scheduler.tick(false); + } + long[] samples = new long[SAMPLE_TICKS]; + for (int tick = 0; tick < SAMPLE_TICKS; tick++) { + long started = System.nanoTime(); + scheduler.tick(false); + samples[tick] = System.nanoTime() - started; + } + Arrays.sort(samples); + long p95 = samples[(int) Math.ceil(samples.length * 0.95) - 1]; + System.out.printf( + "Computed Lua benchmark: nodes=%d samples=%d p95=%.3fms%n", + NODE_COUNT, + SAMPLE_TICKS, + p95 / 1_000_000.0); + assertTrue( + p95 <= MAX_P95_NANOS, + () -> "500-node Lua scheduler p95 was " + p95 / 1_000_000.0 + "ms"); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftChannelsTest.java b/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftChannelsTest.java new file mode 100644 index 0000000..a98804e --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftChannelsTest.java @@ -0,0 +1,100 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dan200.computercraft.api.filesystem.Mount; +import dan200.computercraft.api.filesystem.WritableMount; +import dan200.computercraft.api.peripheral.IComputerAccess; +import dan200.computercraft.api.peripheral.IPeripheral; +import dan200.computercraft.api.peripheral.WorkMonitor; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class ComputerCraftChannelsTest { + @Test + void channelsReadWritePublishAndNotifyAttachedComputers() throws Exception { + ComputerCraftChannels.Store store = new ComputerCraftChannels.Store(); + Access access = new Access(); + store.attach(access); + + store.write("control", Map.of("value", 12)); + store.publish("status", List.of("ready", true)); + store.publish("status", List.of("ready", true)); + + assertEquals(Map.of("value", 12.0), store.input("control")); + assertEquals(Map.of(1, "ready", 2, true), store.output("status")); + assertEquals(List.of("control", "status"), store.channels()); + assertEquals(1, access.events.size()); + assertEquals("computed_output_changed", access.events.getFirst()[0]); + + store.detach(access); + store.publish("status", List.of("changed")); + assertEquals(1, access.events.size()); + } + + private static final class Access implements IComputerAccess { + private final List events = new ArrayList<>(); + + @Override + public String mount(String desiredLocation, Mount mount, String driveName) { + return ""; + } + + @Override + public String mountWritable(String desiredLocation, WritableMount mount, String driveName) { + return ""; + } + + @Override + public void unmount(String location) {} + + @Override + public int getID() { + return 1; + } + + @Override + public void queueEvent(String event, Object... arguments) { + Object[] captured = new Object[arguments.length + 1]; + captured[0] = event; + System.arraycopy(arguments, 0, captured, 1, arguments.length); + events.add(captured); + } + + @Override + public String getAttachmentName() { + return "test"; + } + + @Override + public Map getAvailablePeripherals() { + return Map.of(); + } + + @Override + public IPeripheral getAvailablePeripheral(String name) { + return null; + } + + @Override + public WorkMonitor getMainThreadMonitor() { + return new WorkMonitor() { + @Override + public boolean canWork() { + return true; + } + + @Override + public boolean shouldWork() { + return true; + } + + @Override + public void trackWork(long time, TimeUnit unit) {} + }; + } + } +} diff --git a/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftOptionalLoadingTest.java b/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftOptionalLoadingTest.java new file mode 100644 index 0000000..e6d3170 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftOptionalLoadingTest.java @@ -0,0 +1,19 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class ComputerCraftOptionalLoadingTest { + @Test + void bootstrapBytecodeDoesNotResolveComputerCraftApiTypes() throws Exception { + String resource = '/' + ComputerCraftBootstrap.class.getName().replace('.', '/') + ".class"; + try (InputStream stream = ComputerCraftBootstrap.class.getResourceAsStream(resource)) { + byte[] bytecode = stream.readAllBytes(); + String constants = new String(bytecode, StandardCharsets.ISO_8859_1); + assertFalse(constants.contains("dan200/computercraft")); + } + } +} diff --git a/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftPeripheralCallTest.java b/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftPeripheralCallTest.java new file mode 100644 index 0000000..c653be3 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftPeripheralCallTest.java @@ -0,0 +1,143 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dan200.computercraft.api.lua.IArguments; +import dan200.computercraft.api.lua.ILuaContext; +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.api.lua.LuaFunction; +import dan200.computercraft.api.lua.MethodResult; +import dan200.computercraft.api.peripheral.IComputerAccess; +import dan200.computercraft.api.peripheral.IDynamicPeripheral; +import dan200.computercraft.api.peripheral.IPeripheral; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaTable; + +class ComputerCraftPeripheralCallTest { + @Test + void supportsImmediateDynamicCallsAndAttachmentLifecycle() throws Exception { + DynamicPeripheral peripheral = new DynamicPeripheral(false); + + EndpointResult result = ComputerCraftPeripheralCall.invoke( + null, + "north", + peripheral, + "echo", + List.of("hello")); + + EndpointResult.Immediate immediate = assertInstanceOf(EndpointResult.Immediate.class, result); + LuaTable values = immediate.values().getFirst().checktable(); + assertEquals("hello", values.get(1).tojstring()); + assertEquals(1, peripheral.attachments); + assertEquals(1, peripheral.detachments); + } + + @Test + void resumesYieldedCallsFromPeripheralEvents() throws Exception { + DynamicPeripheral peripheral = new DynamicPeripheral(true); + + EndpointResult result = ComputerCraftPeripheralCall.invoke( + null, + "north", + peripheral, + "echo", + List.of("waiting")); + EndpointResult.Yielded yielded = assertInstanceOf(EndpointResult.Yielded.class, result); + assertEquals(0, peripheral.detachments); + + peripheral.access.queueEvent("resume", "done"); + EndpointResult.Immediate resumed = yielded.continuation().toCompletableFuture().join(); + + assertEquals("done", resumed.values().getFirst().checktable().get(1).tojstring()); + assertEquals(1, peripheral.detachments); + } + + @Test + void discoversAndCallsAnnotatedMainThreadMethods() throws Exception { + AnnotatedPeripheral peripheral = new AnnotatedPeripheral(); + + assertTrue(ComputerCraftPeripheralCall.methods(peripheral).contains("main")); + EndpointResult result = ComputerCraftPeripheralCall.invoke( + null, + "north", + peripheral, + "main", + List.of(3.0)); + + LuaTable values = assertInstanceOf(EndpointResult.Immediate.class, result) + .values() + .getFirst() + .checktable(); + assertEquals(6, values.get(1).toint()); + } + + private static final class DynamicPeripheral implements IDynamicPeripheral { + private final boolean yielding; + private IComputerAccess access; + private int attachments; + private int detachments; + + private DynamicPeripheral(boolean yielding) { + this.yielding = yielding; + } + + @Override + public String getType() { + return "test"; + } + + @Override + public String[] getMethodNames() { + return new String[] {"echo"}; + } + + @Override + public MethodResult callMethod( + IComputerAccess computer, + ILuaContext context, + int method, + IArguments arguments) throws LuaException { + if (!yielding) { + return MethodResult.of(arguments.get(0)); + } + return MethodResult.pullEvent("resume", event -> MethodResult.of(event[1])); + } + + @Override + public void attach(IComputerAccess computer) { + access = computer; + attachments++; + } + + @Override + public void detach(IComputerAccess computer) { + detachments++; + } + + @Override + public boolean equals(IPeripheral other) { + return other == this; + } + } + + private static final class AnnotatedPeripheral implements IPeripheral { + @Override + public String getType() { + return "annotated"; + } + + @LuaFunction(value = "main", mainThread = true) + public final MethodResult main(double value) { + return MethodResult.of(value * 2); + } + + @Override + public boolean equals(IPeripheral other) { + return other == this; + } + } +} diff --git a/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftValueCodecTest.java b/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftValueCodecTest.java new file mode 100644 index 0000000..df722b7 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/integration/computercraft/ComputerCraftValueCodecTest.java @@ -0,0 +1,47 @@ +package dev.propulsionteam.computed.integration.computercraft; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dan200.computercraft.api.lua.LuaException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +class ComputerCraftValueCodecTest { + @Test + void convertsSupportedValuesWithoutExposingJavaObjects() throws LuaException { + Map source = new LinkedHashMap<>(); + source.put("enabled", true); + source.put(2, List.of("a", 4.5)); + + LuaValue encoded = ComputerCraftValueCodec.toLua(source); + Object decoded = ComputerCraftValueCodec.toJava(encoded); + + assertTrue(encoded.istable()); + assertEquals( + Map.of("enabled", true, 2, Map.of(1, "a", 2, 4.5)), + decoded); + } + + @Test + void rejectsCyclesAndUnsupportedObjects() { + Map cycle = new LinkedHashMap<>(); + cycle.put("self", cycle); + + assertThrows(LuaException.class, () -> ComputerCraftValueCodec.toLua(cycle)); + assertThrows(LuaException.class, () -> ComputerCraftValueCodec.toLua(new Object())); + } + + @Test + void rejectsCyclicLuaTables() { + LuaTable cycle = new LuaTable(); + cycle.set("self", cycle); + + assertThrows(LuaException.class, () -> ComputerCraftValueCodec.toJava(cycle)); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/internal/node/ProgramBridgeTest.java b/src/test/java/dev/propulsionteam/computed/internal/node/ProgramBridgeTest.java deleted file mode 100644 index 1110e87..0000000 --- a/src/test/java/dev/propulsionteam/computed/internal/node/ProgramBridgeTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package dev.propulsionteam.computed.internal.node; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import dev.propulsionteam.computed.node.program.ComputedProgram; -import dev.propulsionteam.computed.node.program.ConnectionModel; -import dev.propulsionteam.computed.node.program.FunctionModel; -import dev.propulsionteam.computed.node.program.GraphModel; -import dev.propulsionteam.computed.node.program.NodeModel; -import dev.propulsionteam.computed.node.program.NodeModel.PlaceholderStatus; -import dev.propulsionteam.computed.node.program.PortId; -import dev.propulsionteam.computed.node.program.PortModel; -import dev.propulsionteam.computed.node.program.PortModel.Direction; -import java.util.List; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; -import org.junit.jupiter.api.Test; - -class ProgramBridgeTest { - @Test - void editorStructureKeepsServerAuthoritativeRuntimeState() { - UUID nodeId = new UUID(0L, 1L); - CompoundTag liveState = new CompoundTag(); - liveState.putInt("count", 41); - CompoundTag staleEditorState = new CompoundTag(); - staleEditorState.putInt("count", 3); - CompoundTag editedInnerGraph = new CompoundTag(); - editedInnerGraph.putInt("editMarker", 7); - staleEditorState.put("innerGraph", editedInnerGraph); - - ComputedProgram live = program(node(nodeId, "computed:counter", 10, liveState)); - ComputedProgram incoming = program(node(nodeId, "computed:counter", 99, staleEditorState)); - - ComputedProgram merged = ProgramBridge.preserveRuntimeState(incoming, live); - NodeModel result = merged.rootGraph().nodes().getFirst(); - - assertEquals(99, result.x(), "editor-owned position must be retained"); - assertEquals(41, result.state().getInt("count"), "live state must not rewind to the editor snapshot"); - assertEquals(7, result.state().getCompound("innerGraph").getInt("editMarker")); - } - - @Test - void replacedNodeTypeKeepsItsIncomingDefaultState() { - UUID nodeId = new UUID(0L, 2L); - CompoundTag liveState = new CompoundTag(); - liveState.putInt("count", 41); - CompoundTag replacementState = new CompoundTag(); - replacementState.putInt("value", 8); - - ComputedProgram live = program(node(nodeId, "computed:counter", 0, liveState)); - ComputedProgram incoming = program(node(nodeId, "addon:replacement", 0, replacementState)); - - NodeModel result = ProgramBridge.preserveRuntimeState(incoming, live).rootGraph().nodes().getFirst(); - assertEquals(8, result.state().getInt("value")); - } - - @Test - void analyzesRootAndEveryFunctionGraphIndependently() { - NodeModel rootNode = wiredNode(new UUID(0L, 10L)); - GraphModel root = new GraphModel(new UUID(0L, 100L), List.of(rootNode), List.of(), List.of()); - - NodeModel first = wiredNode(new UUID(0L, 11L)); - NodeModel second = wiredNode(new UUID(0L, 12L)); - GraphModel cyclicFunction = new GraphModel( - new UUID(0L, 101L), - List.of(first, second), - List.of(connection(20L, first, second), connection(21L, second, first)), - List.of()); - FunctionModel function = new FunctionModel( - new UUID(0L, 200L), "Cycle", cyclicFunction, new CompoundTag(), new CompoundTag()); - - List analyses = ProgramBridge.analyzeAll( - new ComputedProgram(0L, root, List.of(function), List.of(), new CompoundTag())); - - assertEquals(List.of(root.id(), cyclicFunction.id()), analyses.stream() - .map(ProgramBridge.AnalyzedGraph::graphId) - .toList()); - assertEquals(0, analyses.getFirst().analysis().combinationalCycles().size()); - assertEquals(1, analyses.get(1).analysis().combinationalCycles().size()); - assertEquals(java.util.Set.of(first.id(), second.id()), analyses.get(1).analysis().disabledNodes()); - } - - private static ComputedProgram program(NodeModel node) { - return new ComputedProgram(new GraphModel(new UUID(0L, 100L), List.of(node), List.of(), List.of()), List.of()); - } - - private static NodeModel node(UUID id, String type, int x, CompoundTag state) { - return new NodeModel( - id, - type, - type, - type, - x, - 0, - new CompoundTag(), - state, - List.of(), - PlaceholderStatus.RESOLVED, - new CompoundTag()); - } - - private static NodeModel wiredNode(UUID id) { - return new NodeModel( - id, - "computed:pure", - "computed:pure", - "Pure", - 0, - 0, - new CompoundTag(), - new CompoundTag(), - List.of( - new PortModel(new PortId("input"), Direction.INPUT, "number", "Input", new CompoundTag()), - new PortModel(new PortId("output"), Direction.OUTPUT, "number", "Output", new CompoundTag())), - PlaceholderStatus.RESOLVED, - new CompoundTag()); - } - - private static ConnectionModel connection(long id, NodeModel source, NodeModel target) { - return new ConnectionModel( - new UUID(0L, id), - source.id(), - new PortId("output"), - target.id(), - new PortId("input"), - List.of(), - new CompoundTag()); - } -} diff --git a/src/test/java/dev/propulsionteam/computed/internal/node/api/InternalNodeTypeAdapterTest.java b/src/test/java/dev/propulsionteam/computed/internal/node/api/InternalNodeTypeAdapterTest.java deleted file mode 100644 index a8f9889..0000000 --- a/src/test/java/dev/propulsionteam/computed/internal/node/api/InternalNodeTypeAdapterTest.java +++ /dev/null @@ -1,203 +0,0 @@ -package dev.propulsionteam.computed.internal.node.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotSame; - -import dev.propulsionteam.computed.api.node.DiagnosticSink; -import dev.propulsionteam.computed.api.node.ExecutionPolicy; -import dev.propulsionteam.computed.api.node.NodeExecutionContext; -import dev.propulsionteam.computed.api.node.NodePropertyBag; -import dev.propulsionteam.computed.api.node.NodeType; -import dev.propulsionteam.computed.api.node.PortKey; -import dev.propulsionteam.computed.api.node.PortType; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import net.minecraft.core.BlockPos; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.server.level.ServerLevel; -import org.junit.jupiter.api.Test; - -class InternalNodeTypeAdapterTest { - private static final ResourceLocation CATEGORY = - ResourceLocation.fromNamespaceAndPath("computed_test", "category"); - - @Test - void descriptorExecutesTypedInternalNodeWithoutMutatingPriorState() throws Exception { - ResourceLocation id = ResourceLocation.fromNamespaceAndPath("computed_test", "sum"); - NodeRegistry.NodeFactory factory = (x, y) -> { - WNode node = new WNode(id, "Sum", x, y); - node.addInput("lhs", "Left renamed", WPin.DataType.NUMBER, 0xFFFFFFFF); - node.addInput("rhs", "Right", WPin.DataType.NUMBER, 0xFFFFFFFF); - node.addOutput("sum", "Sum", WPin.DataType.NUMBER, 0xFFFFFFFF); - node.setEvaluator(n -> n.getOutputs().getFirst().setValue( - n.getInputs().get(0).getValue() + n.getInputs().get(1).getValue())); - return node; - }; - NodeType type = InternalNodeTypeAdapter.describe(factory, factory.create(0, 0), CATEGORY); - FakeContext context = new FakeContext(); - context.input(PortKey.of("lhs", PortType.NUMBER), 2.25); - context.input(PortKey.of("rhs", PortType.NUMBER), 3.75); - CompoundTag prior = type.defaultState(); - CompoundTag priorSnapshot = prior.copy(); - - CompoundTag next = type.evaluator().execute(prior, context); - - assertEquals(6.0, context.output(PortKey.of("sum", PortType.NUMBER))); - assertEquals(priorSnapshot, prior, "the adapter must treat prior state as immutable"); - assertNotSame(prior, next); - assertEquals(ExecutionPolicy.INPUT_DRIVEN, type.executionPolicy()); - assertEquals("Left renamed", type.schema(type.defaultProperties()).requirePort("lhs").label().getString()); - } - - @Test - void fullNbtStateFlowsFromOnePublicExecutionToTheNext() throws Exception { - ResourceLocation id = ResourceLocation.fromNamespaceAndPath("computed_test", "accumulator"); - NodeRegistry.NodeFactory factory = (x, y) -> new AccumulatorNode(id, x, y); - NodeType type = InternalNodeTypeAdapter.describe(factory, factory.create(0, 0), CATEGORY); - PortKey add = PortKey.of("add", PortType.NUMBER); - PortKey total = PortKey.of("total", PortType.NUMBER); - FakeContext first = new FakeContext(); - first.input(add, 4.0); - - CompoundTag state1 = type.evaluator().execute(type.defaultState(), first); - assertEquals(4.0, first.output(total)); - - FakeContext second = new FakeContext(); - second.input(add, 1.5); - CompoundTag state2 = type.evaluator().execute(state1, second); - - assertEquals(5.5, second.output(total)); - assertEquals(5.5, state2.getDouble("Total")); - assertEquals(ExecutionPolicy.EVERY_GRAPH_STEP, type.executionPolicy()); - assertEquals(true, type.stateBoundary()); - } - - @Test - void tickDescriptorUsesGameTicksAndPersistsItsAccumulator() throws Exception { - NodeRegistry.NodeFactory factory = (x, y) -> { - WNode node = new WNode(WGraph.TICK_NODE_TYPE, "Tick", x, y); - node.addOutput("Tick", WPin.DataType.NUMBER, 0xFFFFFFFF); - node.addOutput("Delta time", WPin.DataType.NUMBER, 0xFFFFFFFF); - return node; - }; - NodeType type = InternalNodeTypeAdapter.describe(factory, factory.create(0, 0), CATEGORY); - FakeContext first = new FakeContext(); - first.gameTick = 100; - - CompoundTag state1 = type.evaluator().execute(type.defaultState(), first); - - assertEquals(1.0, first.output(PortKey.of("output.tick", PortType.NUMBER))); - assertEquals(0.05, first.output(PortKey.of("output.delta_time", PortType.NUMBER)), 1.0e-9); - assertEquals(ExecutionPolicy.EVERY_GAME_TICK, type.executionPolicy()); - - FakeContext second = new FakeContext(); - second.gameTick = 101; - type.evaluator().execute(state1, second); - assertEquals(1.0, second.output(PortKey.of("output.tick", PortType.NUMBER))); - assertEquals(0.05, second.output(PortKey.of("output.delta_time", PortType.NUMBER)), 1.0e-9); - } - - private static final class AccumulatorNode extends WNode { - private double total; - - private AccumulatorNode(ResourceLocation id, int x, int y) { - super(id, "Accumulator", x, y); - addInput("add", "Add", WPin.DataType.NUMBER, 0xFFFFFFFF); - addOutput("total", "Total", WPin.DataType.NUMBER, 0xFFFFFFFF); - setEvaluator(node -> { - total += node.getInputs().getFirst().getValue(); - node.getOutputs().getFirst().setValue(total); - }); - } - - @Override - public boolean isStateBoundary() { - return true; - } - - @Override - public CompoundTag save() { - CompoundTag tag = super.save(); - tag.putDouble("Total", total); - return tag; - } - - @Override - public void load(CompoundTag tag) { - super.load(tag); - total = tag.getDouble("Total"); - getOutputs().getFirst().setValue(total); - } - } - - private static final class FakeContext implements NodeExecutionContext { - private final Map, Object> inputs = new HashMap<>(); - private final Map, Object> outputs = new HashMap<>(); - private long gameTick; - - private void input(PortKey key, T value) { - inputs.put(key, value); - } - - private T output(PortKey key) { - return key.type().castOrDefault(outputs.get(key)); - } - - @Override - public NodePropertyBag properties() { - return NodePropertyBag.empty(); - } - - @Override - public T input(PortKey key) { - return key.type().castOrDefault(inputs.get(key)); - } - - @Override - public void output(PortKey key, T value) { - outputs.put(key, value); - } - - @Override - public boolean isInputConnected(PortKey key) { - return inputs.containsKey(key); - } - - @Override - public long gameTick() { - return gameTick; - } - - @Override - public long graphStep() { - return gameTick; - } - - @Override - public boolean isPreview() { - return true; - } - - @Override - public Optional level() { - return Optional.empty(); - } - - @Override - public Optional origin() { - return Optional.empty(); - } - - @Override - public boolean sideEffectsAllowed() { - return false; - } - - @Override - public DiagnosticSink diagnostics() { - return ignored -> {}; - } - } -} diff --git a/src/test/java/dev/propulsionteam/computed/internal/node/client/editor/NodeDescriptionCatalogTest.java b/src/test/java/dev/propulsionteam/computed/internal/node/client/editor/NodeDescriptionCatalogTest.java deleted file mode 100644 index 102c0ef..0000000 --- a/src/test/java/dev/propulsionteam/computed/internal/node/client/editor/NodeDescriptionCatalogTest.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.propulsionteam.computed.internal.node.client.editor; - -import static org.junit.jupiter.api.Assertions.*; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import org.junit.jupiter.api.Test; - -class NodeDescriptionCatalogTest { - @Test void coversTheCompleteBuiltInPaletteAndFallsBackForAddons() { - assertEquals(85, NodeDescriptionCatalog.builtInDescriptionCount()); - ResourceLocation add = ResourceLocation.fromNamespaceAndPath("computed", "math_add"); - assertTrue(NodeDescriptionCatalog.hasBuiltInDescription(add)); - assertEquals("Adds the Teleport node.", NodeDescriptionCatalog.description( - ResourceLocation.fromNamespaceAndPath("addon", "teleport"), Component.literal("Teleport"))); - } -} diff --git a/src/test/java/dev/propulsionteam/computed/lua/compiler/LuaSourceCompilerTest.java b/src/test/java/dev/propulsionteam/computed/lua/compiler/LuaSourceCompilerTest.java new file mode 100644 index 0000000..09cdaa4 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/compiler/LuaSourceCompilerTest.java @@ -0,0 +1,40 @@ +package dev.propulsionteam.computed.lua.compiler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LuaSourceCompilerTest { + @BeforeEach + void resetCache() { + LuaSourceCompiler.clearCache(); + } + + @Test + void cachesPrototypesByApiVersionAndSourceHash() { + LuaSourceCompiler firstCompiler = new LuaSourceCompiler(); + LuaSourceCompiler secondCompiler = new LuaSourceCompiler(); + + LuaCompiledSource first = firstCompiler.compile(1, "return 42"); + LuaCompiledSource second = secondCompiler.compile(1, "return 42"); + LuaCompiledSource otherApi = secondCompiler.compile(2, "return 42"); + + assertEquals(first.sourceHash(), second.sourceHash()); + assertSame(first.prototype(), second.prototype()); + assertNotSame(first.prototype(), otherApi.prototype()); + assertEquals(2, LuaSourceCompiler.cachedPrototypeCount()); + } + + @Test + void rejectsMalformedAndOversizedSources() { + LuaSourceCompiler compiler = new LuaSourceCompiler(); + assertThrows(LuaCompilationException.class, () -> compiler.compile(1, "local =")); + assertThrows( + LuaCompilationException.class, + () -> compiler.compile(1, "x".repeat(LuaSourceCompiler.MAX_SOURCE_BYTES + 1))); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/node/BundledLuaLibraryTest.java b/src/test/java/dev/propulsionteam/computed/lua/node/BundledLuaLibraryTest.java new file mode 100644 index 0000000..76f2ba1 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/node/BundledLuaLibraryTest.java @@ -0,0 +1,146 @@ +package dev.propulsionteam.computed.lua.node; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpointHost; +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpoints; +import dev.propulsionteam.computed.lua.endpoint.BuiltinWidget; +import dev.propulsionteam.computed.lua.runtime.LuaComputerRuntime; +import dev.propulsionteam.computed.lua.runtime.LuaNodeStatus; +import dev.propulsionteam.computed.lua.sandbox.LuaInstructionBudget; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class BundledLuaLibraryTest { + @Test + void everyBundledDefinitionCompilesAndReturnsItsRegisteredId() { + var compiler = new LuaSourceCompiler(); + var loader = new LuaDefinitionLoader(); + var sandbox = new LuaSandbox(new LuaInstructionBudget()); + var definitions = BundledLuaLibrary.load(); + + assertTrue(definitions.size() >= 30); + definitions.forEach((id, source) -> { + var compiled = compiler.compile(source.apiVersion(), source.source()); + var definition = loader.load(compiled, sandbox); + assertEquals(id, definition.id()); + }); + } + + @Test + void everyBundledDefinitionRunsWithItsDefaults() { + BuiltinEndpoints.register(); + var host = new BuiltinEndpointHost() { + @Override + public double worldTime() { + return 6000; + } + + @Override + public double[] position() { + return new double[] {0.5, 64.5, 0.5}; + } + + @Override + public double[] rotation() { + return new double[] {0, 0, 0}; + } + + @Override + public int redstoneInput(String face) { + return 0; + } + + @Override + public int comparatorInput(String face) { + return 0; + } + + @Override + public boolean blockPresent(String face) { + return false; + } + + @Override + public void redstoneOutput(String face, int level) {} + + @Override + public void runCommand(String command) {} + }; + var runtime = new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); + runtime.beginTick(1); + + BundledLuaLibrary.load().forEach((id, source) -> { + var instance = runtime.createNode(UUID.randomUUID(), source.apiVersion(), source.source()); + var inputs = new LinkedHashMap(); + instance.definition().inputs().forEach(port -> inputs.put(port.id(), port.defaultValue())); + var fields = new LinkedHashMap(); + instance.definition().fields().forEach(field -> fields.put(field.id(), field.defaultValue())); + var result = instance.run(inputs, fields, 1, runtime.nextGraphStep(), false, (name, values) -> {}); + assertEquals(LuaNodeStatus.IDLE, result.status(), id + ": " + result.diagnostics()); + assertEquals(List.of(), result.diagnostics(), id); + }); + } + + @Test + void monitorDefinitionCallsItsProductionEndpoint() { + BuiltinEndpoints.register(); + var calls = new java.util.ArrayList>(); + var targets = new java.util.ArrayList(); + var host = new BuiltinEndpointHost() { + @Override + public double worldTime() { + return 0; + } + + @Override + public void runCommand(String command) {} + + @Override + public void showWidgets(String target, List widgets) { + targets.add(target); + calls.add(widgets); + } + }; + var source = BundledLuaLibrary.load().get("computed:peripheral"); + var runtime = new LuaComputerRuntime(UUID.randomUUID(), new LuaInstructionBudget(), host); + runtime.beginTick(1); + var instance = runtime.createNode(UUID.randomUUID(), source.apiVersion(), source.source()); + var inputs = new LinkedHashMap(); + instance.definition().inputs().forEach(port -> inputs.put(port.id(), port.defaultValue())); + UUID widgetId = UUID.randomUUID(); + var widget = new org.luaj.vm2.LuaTable(); + widget.set("id", widgetId.toString()); + widget.set("type", "text"); + widget.set("text", "Monitor output"); + widget.set("x", 4); + widget.set("y", 6); + widget.set("width", 48); + widget.set("height", 12); + inputs.put("widget_1", widget); + var fields = new LinkedHashMap(); + instance.definition().fields().forEach(field -> fields.put(field.id(), field.defaultValue())); + + var result = instance.run(inputs, fields, 1, runtime.nextGraphStep(), false, (name, values) -> {}); + + assertEquals(LuaNodeStatus.IDLE, result.status()); + assertEquals(List.of("front"), targets); + assertEquals(1, calls.size()); + assertEquals(1, calls.getFirst().size()); + BuiltinWidget shown = calls.getFirst().getFirst(); + assertEquals(widgetId, shown.id()); + assertEquals("text", shown.type()); + assertEquals("Monitor output", shown.properties().get("text")); + assertEquals(4, shown.x()); + assertEquals(6, shown.y()); + assertEquals("line", shown.properties().get("layout_mode")); + assertEquals(1.0, shown.properties().get("line")); + assertEquals(1.0, shown.properties().get("span")); + assertEquals("auto", shown.properties().get("fit")); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/node/BundledNodeFieldParityTest.java b/src/test/java/dev/propulsionteam/computed/lua/node/BundledNodeFieldParityTest.java new file mode 100644 index 0000000..9d5b289 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/node/BundledNodeFieldParityTest.java @@ -0,0 +1,103 @@ +package dev.propulsionteam.computed.lua.node; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.client.editor.preview.LuaLivePreview; +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaValue; + +class BundledNodeFieldParityTest { + @Test + void restoresLegacySliderRangesAndWidgetConfiguration() { + Map library = + BundledLuaLibrary.load(); + LuaNodeDefinition level = load(library, "computed:level_to_bool"); + LuaNodeDefinition schmitt = load(library, "computed:schmitt"); + LuaNodeDefinition approximate = load(library, "computed:cmp_approx"); + LuaNodeDefinition delay = load(library, "computed:delay"); + LuaNodeDefinition oscillator = load(library, "computed:oscillator"); + LuaNodeDefinition pulse = load(library, "computed:pulse"); + LuaNodeDefinition tick = load(library, "computed:tick"); + LuaNodeDefinition clock = load(library, "computed:clock_widget"); + LuaNodeDefinition text = load(library, "computed:text_widget"); + + assertSlider(level, "threshold", 0, 15, 8); + assertSlider(schmitt, "low", 0, 15, 5); + assertSlider(schmitt, "high", 0, 15, 10); + assertSlider(approximate, "epsilon", 0, 15, 0.5); + assertSlider(delay, "delay", 0, 200, 1); + assertSlider(oscillator, "period", 1, 200, 20); + assertSlider(oscillator, "amplitude", 1, 100, 1); + assertSlider(pulse, "period", 1, 20, 20); + assertSlider(tick, "rate", 0, 20, 20); + assertNotNull(field(clock, "width")); + assertNotNull(field(clock, "alignment")); + assertNotNull(field(text, "width")); + assertEquals(FieldType.CHOICE, field(text, "alignment").type()); + library.values().forEach(source -> { + LuaNodeDefinition definition = load(source); + definition.fields().forEach(field -> + assertNull(LuaFieldValues.validationError(field, field.defaultValue()))); + }); + } + + @Test + void restoredThresholdAndDelayAffectRuntimeBehavior() { + Map library = + BundledLuaLibrary.load(); + LuaLivePreview threshold = new LuaLivePreview(library.get("computed:level_to_bool").source()); + threshold.setInput("level", LuaValue.valueOf(7)); + threshold.setField("threshold", LuaValue.valueOf(8)); + assertTrue(!threshold.run().outputs().get("value").toboolean()); + threshold.setField("threshold", LuaValue.valueOf(6)); + assertTrue(threshold.run().outputs().get("value").toboolean()); + + LuaLivePreview delay = new LuaLivePreview(library.get("computed:delay").source()); + delay.setField("delay", LuaValue.valueOf(2)); + delay.setInput("value", LuaValue.valueOf(1)); + delay.run(); + delay.setInput("value", LuaValue.valueOf(2)); + delay.run(); + delay.setInput("value", LuaValue.valueOf(3)); + assertEquals(1, delay.run().outputs().get("delayed").toint()); + } + + private static LuaNodeDefinition load( + Map library, + String id) { + return load(library.get(id)); + } + + private static LuaNodeDefinition load( + dev.propulsionteam.computed.graph.LuaDefinitionSource source) { + return new LuaDefinitionLoader().load( + new LuaSourceCompiler().compile(source.apiVersion(), source.source()), + new LuaSandbox()); + } + + private static void assertSlider( + LuaNodeDefinition definition, + String id, + double minimum, + double maximum, + double defaultValue) { + LuaFieldSchema field = field(definition, id); + assertEquals(FieldControl.SLIDER, field.control()); + assertEquals(minimum, field.minimum()); + assertEquals(maximum, field.maximum()); + assertEquals(defaultValue, field.defaultValue().todouble()); + } + + private static LuaFieldSchema field(LuaNodeDefinition definition, String id) { + return definition.fields().stream() + .filter(field -> field.id().equals(id)) + .findFirst() + .orElseThrow(); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/node/IntegrationLuaLibraryTest.java b/src/test/java/dev/propulsionteam/computed/lua/node/IntegrationLuaLibraryTest.java new file mode 100644 index 0000000..14abf17 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/node/IntegrationLuaLibraryTest.java @@ -0,0 +1,28 @@ +package dev.propulsionteam.computed.lua.node; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import org.junit.jupiter.api.Test; + +class IntegrationLuaLibraryTest { + @Test + void everyIntegrationDefinitionCompilesAndMatchesItsId() { + LuaSourceCompiler compiler = new LuaSourceCompiler(); + LuaDefinitionLoader loader = new LuaDefinitionLoader(); + LuaSandbox sandbox = new LuaSandbox(); + var definitions = IntegrationLuaLibrary.load(); + + assertEquals(5, definitions.size()); + definitions.forEach((id, source) -> { + LuaNodeDefinition definition = + loader.load(compiler.compile(source.apiVersion(), source.source()), sandbox); + assertEquals(id, definition.id()); + assertEquals(dev.propulsionteam.computed.graph.LuaDefinitionSource.Origin.INTEGRATION, source.origin()); + }); + assertTrue(definitions.keySet().stream().anyMatch(id -> id.startsWith("computed:cc_"))); + assertTrue(definitions.keySet().stream().anyMatch(id -> id.startsWith("computed:create_"))); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLibraryTest.java b/src/test/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLibraryTest.java new file mode 100644 index 0000000..cef9640 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLibraryTest.java @@ -0,0 +1,46 @@ +package dev.propulsionteam.computed.lua.node; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class LuaDefinitionLibraryTest { + @Test + void requiresReplacementConfirmationAndReportsStablePortReconnections() { + LuaDefinitionLibrary library = new LuaDefinitionLibrary(Map.of()); + String first = source("number", "number"); + String replacement = source("number", "string"); + + LuaLibraryUpdate added = library.importSource(1, first, false, ignored -> true); + LuaLibraryUpdate unchanged = library.importSource(1, first, false, ignored -> true); + LuaLibraryUpdate confirmation = library.importSource(1, replacement, false, ignored -> true); + LuaLibraryUpdate replaced = library.importSource(1, replacement, true, ignored -> true); + + assertEquals(LuaLibraryUpdate.Status.ADDED, added.status()); + assertEquals(LuaLibraryUpdate.Status.UNCHANGED, unchanged.status()); + assertEquals(LuaLibraryUpdate.Status.CONFIRMATION_REQUIRED, confirmation.status()); + assertEquals(LuaLibraryUpdate.Status.REPLACED, replaced.status()); + assertTrue(replaced.retainedPorts().contains("input:value:NUMBER")); + assertTrue(replaced.removedPorts().contains("output:result:NUMBER")); + assertEquals("output:result:STRING", library.schema("example:convert") + .outputs() + .stream() + .map(port -> "output:" + port.id() + ':' + port.type()) + .findFirst() + .orElseThrow()); + } + + private static String source(String inputType, String outputType) { + return """ + local node = computed.node(1, "example:convert", "Convert") + node:input("value", "%s") + node:output("result", "%s") + node:on_run(function(ctx) + ctx:output("result", ctx:input("value")) + end) + return node + """.formatted(inputType, outputType); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLoaderTest.java b/src/test/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLoaderTest.java new file mode 100644 index 0000000..4d50d89 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/node/LuaDefinitionLoaderTest.java @@ -0,0 +1,103 @@ +package dev.propulsionteam.computed.lua.node; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import dev.propulsionteam.computed.lua.sandbox.LuaSandbox; +import org.junit.jupiter.api.Test; + +class LuaDefinitionLoaderTest { + private final LuaSourceCompiler compiler = new LuaSourceCompiler(); + private final LuaDefinitionLoader loader = new LuaDefinitionLoader(); + + @Test + void loadsTheFluentDefinitionContract() { + String source = """ + local node = computed.node(1, "example:counter", "Counter") + node:category("state") + node:style("compact") + node:input("increment", "number", { default = 1 }) + node:output("count", "number") + node:field("step", "number", { + default = 2, + min = 0, + max = 10, + control = "slider", + step = 0.5, + label = "Step Size" + }) + node:state("count", 0) + node:execution("tick") + node:on_run(function(ctx) + ctx:output("count", ctx:state("count")) + end) + return node + """; + + LuaNodeDefinition definition = + loader.load(compiler.compile(1, source), new LuaSandbox()); + + assertEquals("example:counter", definition.id()); + assertEquals("state", definition.category()); + assertEquals(NodeStyle.COMPACT, definition.style()); + assertEquals(LuaExecutionPolicy.TICK, definition.executionPolicy()); + assertEquals(ConnectionType.NUMBER, definition.inputs().getFirst().type()); + assertEquals(2.0, definition.fields().getFirst().defaultValue().todouble()); + assertEquals(FieldControl.SLIDER, definition.fields().getFirst().control()); + assertEquals(0.5, definition.fields().getFirst().step()); + assertEquals("Step Size", definition.fields().getFirst().label()); + assertEquals(0.0, definition.stateDefaults().get("count").todouble()); + } + + @Test + void rejectsDuplicateSchemasAndMissingCallbacks() { + String duplicate = """ + local node = computed.node(1, "example:bad", "Bad") + node:input("value", "number") + node:input("value", "number") + node:on_run(function(ctx) end) + return node + """; + String missingCallback = """ + local node = computed.node(1, "example:bad", "Bad") + return node + """; + + assertThrows( + LuaDefinitionException.class, + () -> loader.load(compiler.compile(1, duplicate), new LuaSandbox())); + assertThrows( + LuaDefinitionException.class, + () -> loader.load(compiler.compile(1, missingCallback), new LuaSandbox())); + } + + @Test + void rejectsInvalidFieldPresentationMetadata() { + String missingRange = definitionWithField( + "node:field(\"value\", \"number\", { default = 1, control = \"slider\" })"); + String invalidStep = definitionWithField( + "node:field(\"value\", \"number\", { default = 1, step = 0 })"); + String nonFiniteRange = definitionWithField( + "node:field(\"value\", \"number\", { default = 1, min = -math.huge, max = 2 })"); + + assertThrows( + LuaDefinitionException.class, + () -> loader.load(compiler.compile(1, missingRange), new LuaSandbox())); + assertThrows( + LuaDefinitionException.class, + () -> loader.load(compiler.compile(1, invalidStep), new LuaSandbox())); + assertThrows( + LuaDefinitionException.class, + () -> loader.load(compiler.compile(1, nonFiniteRange), new LuaSandbox())); + } + + private static String definitionWithField(String field) { + return """ + local node = computed.node(1, "example:field", "Field") + %s + node:on_run(function(ctx) end) + return node + """.formatted(field); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/node/LuaDocumentationCoverageTest.java b/src/test/java/dev/propulsionteam/computed/lua/node/LuaDocumentationCoverageTest.java new file mode 100644 index 0000000..869e57f --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/node/LuaDocumentationCoverageTest.java @@ -0,0 +1,74 @@ +package dev.propulsionteam.computed.lua.node; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.endpoint.BuiltinEndpoints; +import dev.propulsionteam.computed.lua.endpoint.ComputedEndpoints; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +class LuaDocumentationCoverageTest { + @Test + void documentsEveryRegisteredLuaAndEndpointMethodHeading() throws IOException { + String luaReference = Files.readString(Path.of("docs/lua/lua-api-reference.md")); + List luaHeadings = List.of( + "computed.node(apiVersion, id, title)", + "node:category(name)", + "node:style(style)", + "node:input(id, type, options)", + "node:output(id, type, options)", + "node:field(id, fieldType, options)", + "node:state(id, defaultValue)", + "node:execution(policy)", + "node:on_run(callback)", + "node:on_event(eventName, callback)", + "ctx:input(id)", + "ctx:output(id, value)", + "ctx:field(id)", + "ctx:state(id)", + "ctx:set_state(id, value)", + "ctx:endpoint(id, target)", + "ctx:emit(eventName, ...)", + "ctx:tick()", + "ctx:graph_step()", + "ctx:is_preview()", + "endpoint:methods()", + "endpoint:call(methodName, ...)"); + luaHeadings.forEach(heading -> assertTrue(luaReference.contains("## " + heading), heading)); + + BuiltinEndpoints.register(); + String endpointReference = Files.readString(Path.of("docs/lua/endpoint-api.md")); + List javaHeadings = List.of( + "ComputedEndpoints.register(id, registration)", + "EndpointBuilder.method(methodId, signature, policy, handler)", + "EndpointBuilder.method(methodId, signature, policy, handler, previewFixture, documentation)", + "ComputedEndpoints.find(id)", + "ComputedEndpoints.definitions()", + "EndpointSignature.of(arguments, returns)", + "EndpointPolicy.computerThread(sideEffect, previewAvailable)", + "EndpointResult.immediate(values...)", + "EndpointResult.yielded(continuation)", + "EndpointResult.unavailable(reason)", + "EndpointRuntimeLifecycle.register(listener)"); + javaHeadings.forEach(heading -> assertTrue(endpointReference.contains("## " + heading), heading)); + ComputedEndpoints.definitions().stream() + .filter(endpoint -> endpoint.id().startsWith("computed:")) + .forEach(endpoint -> endpoint.methods().keySet().forEach(method -> + assertTrue(endpointReference.contains("## " + endpoint.id() + '/' + method)))); + List integrationHeadings = List.of( + "create:kinetic/speed", + "create:kinetic/stress", + "create:kinetic/capacity", + "create:redstone_link/receive", + "create:redstone_link/transmit", + "computercraft:channel/read", + "computercraft:channel/publish", + "computercraft:peripheral/methods", + "computercraft:peripheral/call"); + integrationHeadings.forEach(heading -> + assertTrue(endpointReference.contains("## " + heading), heading)); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaNodeRuntimeTest.java b/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaNodeRuntimeTest.java new file mode 100644 index 0000000..17ab1ec --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaNodeRuntimeTest.java @@ -0,0 +1,93 @@ +package dev.propulsionteam.computed.lua.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.sandbox.LuaInstructionBudget; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaValue; + +class LuaNodeRuntimeTest { + @Test + void commitsAtomicallyAndRetainsTheLastSuccessfulStateAfterFailure() { + LuaComputerRuntime runtime = new LuaComputerRuntime(UUID.randomUUID()); + LuaNodeInstance node = runtime.createNode(UUID.randomUUID(), 1, """ + local node = computed.node(1, "example:counter", "Counter") + node:input("fail", "boolean", { default = false }) + node:output("count", "number") + node:state("count", 0) + node:on_run(function(ctx) + local next = ctx:state("count") + 1 + ctx:set_state("count", next) + ctx:output("count", next) + if ctx:input("fail") then + error("requested failure") + end + end) + return node + """); + runtime.beginTick(1); + + LuaInvocationResult successful = + node.run(Map.of("fail", LuaValue.FALSE), Map.of(), 1, runtime.nextGraphStep(), false, null); + LuaInvocationResult failed = + node.run(Map.of("fail", LuaValue.TRUE), Map.of(), 1, runtime.nextGraphStep(), false, null); + + assertEquals(LuaNodeStatus.IDLE, successful.status()); + assertEquals(1.0, successful.outputs().get("count").todouble()); + assertEquals(LuaNodeStatus.FAILED, failed.status()); + assertEquals(1.0, failed.outputs().get("count").todouble()); + assertEquals(1.0, node.state().get("count").todouble()); + assertFalse(failed.diagnostics().isEmpty()); + assertTrue(runtime.sandbox().budget().tickRemaining() < LuaInstructionBudget.DEFAULT_TICK_LIMIT); + } + + @Test + void abortsAnInfiniteLoopWithoutDiscardingCommittedOutputs() { + LuaInstructionBudget budget = new LuaInstructionBudget(2_000, 20_000); + LuaComputerRuntime runtime = new LuaComputerRuntime(UUID.randomUUID(), budget); + LuaNodeInstance node = runtime.createNode(UUID.randomUUID(), 1, """ + local node = computed.node(1, "example:loop", "Loop") + node:output("value", "number") + node:on_run(function(ctx) + ctx:output("value", 12) + while true do end + end) + return node + """); + runtime.beginTick(1); + + LuaInvocationResult result = + node.run(Map.of(), Map.of(), 1, runtime.nextGraphStep(), false, null); + + assertEquals(LuaNodeStatus.FAILED, result.status()); + assertFalse(result.diagnostics().isEmpty()); + assertFalse(result.outputs().containsKey("value")); + } + + @Test + void abortsExcessiveRecursionWithoutEscapingTheRuntime() { + LuaInstructionBudget budget = new LuaInstructionBudget(50_000, 100_000); + LuaComputerRuntime runtime = new LuaComputerRuntime(UUID.randomUUID(), budget); + LuaNodeInstance node = runtime.createNode(UUID.randomUUID(), 1, """ + local node = computed.node(1, "example:recursion", "Recursion") + node:on_run(function(ctx) + local function recurse() + return recurse() + end + recurse() + end) + return node + """); + runtime.beginTick(1); + + LuaInvocationResult result = + node.run(Map.of(), Map.of(), 1, runtime.nextGraphStep(), false, null); + + assertEquals(LuaNodeStatus.FAILED, result.status()); + assertFalse(result.diagnostics().getFirst().message().isBlank()); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaStateCodecTest.java b/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaStateCodecTest.java new file mode 100644 index 0000000..b74bdde --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaStateCodecTest.java @@ -0,0 +1,50 @@ +package dev.propulsionteam.computed.lua.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +class LuaStateCodecTest { + private final LuaStateCodec codec = new LuaStateCodec(); + + @Test + void roundTripsSupportedValuesAndTableKeys() { + LuaTable nested = new LuaTable(); + nested.set("enabled", LuaValue.TRUE); + LuaTable root = new LuaTable(); + root.set("name", "counter"); + root.set(1, LuaValue.valueOf(4.5)); + root.set(-2, nested); + + LuaTable restored = codec.decode(codec.encode(root)).checktable(); + + assertEquals("counter", restored.get("name").tojstring()); + assertEquals(4.5, restored.get(1).todouble()); + assertTrue(restored.get(-2).get("enabled").toboolean()); + } + + @Test + void rejectsCyclesUnsupportedKeysNonFiniteNumbersAndExcessDepth() { + LuaTable cyclic = new LuaTable(); + cyclic.set("self", cyclic); + LuaTable unsupportedKey = new LuaTable(); + unsupportedKey.set(new LuaTable(), LuaValue.TRUE); + + assertThrows(IllegalArgumentException.class, () -> codec.encode(cyclic)); + assertThrows(IllegalArgumentException.class, () -> codec.encode(unsupportedKey)); + assertThrows(IllegalArgumentException.class, () -> codec.encode(LuaValue.valueOf(Double.NaN))); + + LuaTable root = new LuaTable(); + LuaTable current = root; + for (int index = 0; index < LuaStateCodec.MAX_DEPTH + 1; index++) { + LuaTable child = new LuaTable(); + current.set("next", child); + current = child; + } + assertThrows(IllegalArgumentException.class, () -> codec.encode(root)); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaYieldRuntimeTest.java b/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaYieldRuntimeTest.java new file mode 100644 index 0000000..74a51c5 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/runtime/LuaYieldRuntimeTest.java @@ -0,0 +1,51 @@ +package dev.propulsionteam.computed.lua.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.endpoint.ComputedEndpoints; +import dev.propulsionteam.computed.lua.endpoint.EndpointPolicy; +import dev.propulsionteam.computed.lua.endpoint.EndpointResult; +import dev.propulsionteam.computed.lua.endpoint.EndpointSignature; +import dev.propulsionteam.computed.lua.endpoint.EndpointType; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaValue; + +class LuaYieldRuntimeTest { + @Test + void resumesYieldedEndpointCallsAndCommitsOnlyAfterCompletion() { + CompletableFuture continuation = new CompletableFuture<>(); + String endpointId = "test:yield_" + UUID.randomUUID().toString().replace("-", ""); + ComputedEndpoints.register(endpointId, endpoint -> endpoint.method( + "wait", + EndpointSignature.of(List.of(), List.of(EndpointType.NUMBER)), + new EndpointPolicy(EndpointPolicy.ExecutionSide.COMPUTER_THREAD, true, false, false), + invocation -> EndpointResult.yielded(continuation))); + LuaComputerRuntime runtime = new LuaComputerRuntime(UUID.randomUUID()); + LuaNodeInstance node = runtime.createNode(UUID.randomUUID(), 1, """ + local node = computed.node(1, "example:yield", "Yield") + node:output("value", "number") + node:on_run(function(ctx) + local endpoint = ctx:endpoint("%s") + ctx:output("value", endpoint:call("wait")) + end) + return node + """.formatted(endpointId)); + runtime.beginTick(1); + + LuaInvocationResult yielded = + node.run(Map.of(), Map.of(), 1, runtime.nextGraphStep(), false, null); + assertEquals(LuaNodeStatus.YIELDED, yielded.status()); + assertTrue(yielded.outputs().isEmpty()); + + continuation.complete(EndpointResult.immediate(LuaValue.valueOf(42))); + LuaInvocationResult resumed = node.resumeIfReady(); + + assertEquals(LuaNodeStatus.IDLE, resumed.status()); + assertEquals(42.0, resumed.outputs().get("value").todouble()); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/lua/sandbox/LuaSandboxTest.java b/src/test/java/dev/propulsionteam/computed/lua/sandbox/LuaSandboxTest.java new file mode 100644 index 0000000..0f6f9ee --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/lua/sandbox/LuaSandboxTest.java @@ -0,0 +1,35 @@ +package dev.propulsionteam.computed.lua.sandbox; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.lua.compiler.LuaSourceCompiler; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaClosure; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +class LuaSandboxTest { + @Test + void exposesOnlyTheApprovedLibrarySurface() { + LuaSandbox sandbox = new LuaSandbox(); + List blocked = List.of( + "io", "os", "debug", "package", "require", "load", "loadfile", "dofile", "luajava"); + + blocked.forEach(name -> assertTrue(sandbox.isBlocked(name), name)); + + LuaTable environment = sandbox.createEnvironment(); + environment.set("java", LuaValue.NIL); + var compiled = new LuaSourceCompiler().compile( + 1, + "return math ~= nil and string ~= nil and table ~= nil and bit32 ~= nil " + + "and coroutine ~= nil and io == nil and os == nil and debug == nil " + + "and package == nil and require == nil and luajava == nil"); + LuaValue result; + try (LuaInstructionBudget.Scope ignored = sandbox.budget().beginInvocation()) { + result = new LuaClosure(compiled.prototype(), environment).call(); + } + + assertTrue(result.toboolean()); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/network/ComputerEditPolicyTest.java b/src/test/java/dev/propulsionteam/computed/network/ComputerEditPolicyTest.java new file mode 100644 index 0000000..f479b5b --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/network/ComputerEditPolicyTest.java @@ -0,0 +1,104 @@ +package dev.propulsionteam.computed.network; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.graph.ComputedGraph; +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.GraphNode; +import dev.propulsionteam.computed.graph.PortDirection; +import dev.propulsionteam.computed.graph.PortSnapshot; +import dev.propulsionteam.computed.lua.node.BundledLuaLibrary; +import dev.propulsionteam.computed.lua.node.ConnectionType; +import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaValue; + +class ComputerEditPolicyTest { + @Test + void enforcesDistanceBuildPermissionAndInteractionPermission() { + assertNull(ComputerEditPolicy.access(ComputerEditPolicy.MAX_DISTANCE_SQ, true, true)); + assertTrue(ComputerEditPolicy.access(ComputerEditPolicy.MAX_DISTANCE_SQ + 1, true, true) + .contains("far")); + assertTrue(ComputerEditPolicy.access(1, false, true).contains("permission")); + assertTrue(ComputerEditPolicy.access(1, true, false).contains("permission")); + assertTrue(ComputerEditPolicy.access(Double.NaN, true, true).contains("far")); + } + + @Test + void rejectsStaleRevisionsAndOversizedPayloads() { + assertNull(ComputerEditPolicy.revision(7, 7)); + assertTrue(ComputerEditPolicy.revision(7, 6).contains("stale")); + assertNull(ComputerEditPolicy.encodedSize(ComputerEditPolicy.MAX_PROGRAM_BYTES)); + assertTrue(ComputerEditPolicy.encodedSize(ComputerEditPolicy.MAX_PROGRAM_BYTES + 1) + .contains("size limit")); + assertTrue(ComputerEditPolicy.encodedSize(-1).contains("measured")); + } + + @Test + void rejectsProgramsPastTheAuthoritativeNodeLimit() { + List nodes = new ArrayList<>(); + for (int index = 0; index <= ComputerEditPolicy.MAX_NODES; index++) { + nodes.add(new GraphNode( + new UUID(0, index + 1L), + "missing:test", + "", + 0, + 0, + List.of(), + Map.of())); + } + ComputedProgramV3 program = new ComputedProgramV3( + 0, + new ComputedGraph(UUID.randomUUID(), nodes, List.of()), + Map.of(), + Map.of(), + null); + + assertTrue(ComputerEditPolicy.programShape(program).contains("node limit")); + } + + @Test + void rejectsWrongFieldTypesAndUndeclaredFields() { + var definition = BundledLuaLibrary.load().get("computed:constant"); + GraphNode wrongType = new GraphNode( + UUID.randomUUID(), + definition.id(), + definition.hash(), + 0, + 0, + List.of(new PortSnapshot( + "value", + PortDirection.OUTPUT, + ConnectionType.NUMBER, + "value")), + Map.of("value", new LuaStateCodec().encode(LuaValue.valueOf("not a number")))); + ComputedProgramV3 wrongTypeProgram = new ComputedProgramV3( + 0, + new ComputedGraph(UUID.randomUUID(), List.of(wrongType), List.of()), + Map.of(), + Map.of(), + null); + GraphNode extraField = new GraphNode( + wrongType.id(), + definition.id(), + definition.hash(), + 0, + 0, + wrongType.ports(), + Map.of("unknown", new LuaStateCodec().encode(LuaValue.ZERO))); + ComputedProgramV3 extraFieldProgram = new ComputedProgramV3( + 0, + new ComputedGraph(UUID.randomUUID(), List.of(extraField), List.of()), + Map.of(), + Map.of(), + null); + + assertTrue(ComputerEditPolicy.programShape(wrongTypeProgram).contains("invalid")); + assertTrue(ComputerEditPolicy.programShape(extraFieldProgram).contains("undeclared")); + } +} diff --git a/src/test/java/dev/propulsionteam/computed/node/program/ProgramCodecTest.java b/src/test/java/dev/propulsionteam/computed/node/program/ProgramCodecTest.java deleted file mode 100644 index 3d11f44..0000000 --- a/src/test/java/dev/propulsionteam/computed/node/program/ProgramCodecTest.java +++ /dev/null @@ -1,317 +0,0 @@ -package dev.propulsionteam.computed.node.program; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.propulsionteam.computed.node.program.ConnectionModel.Waypoint; -import dev.propulsionteam.computed.node.program.NodeModel.PlaceholderStatus; -import dev.propulsionteam.computed.node.program.PortModel.Direction; -import dev.propulsionteam.computed.node.program.ProgramDiagnostic.Severity; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.Tag; -import org.junit.jupiter.api.Test; - -class ProgramCodecTest { - private static final UUID GRAPH_ID = uuid(100); - private static final UUID SOURCE_ID = uuid(101); - private static final UUID TARGET_ID = uuid(102); - private static final UUID CONNECTION_ID = uuid(103); - - @Test - void migratesLegacyIdsStablePinsAndMissingNodesWithoutDiscardingRawData() { - CompoundTag legacy = legacyBundle(); - - ProgramCodec.DecodeResult result = - ProgramCodec.decode(legacy, type -> type.equals("computed:constant")); - ComputedProgram program = result.program(); - - assertTrue(result.migrated()); - assertEquals(0, result.sourceVersion()); - assertEquals(GRAPH_ID, program.rootGraph().id()); - assertEquals(2, program.rootGraph().nodes().size()); - - NodeModel source = program.rootGraph().node(SOURCE_ID).orElseThrow(); - assertEquals("computed:constant", source.typeId()); - assertEquals("websnodelib:constant", source.originalTypeId()); - assertEquals(PlaceholderStatus.RESOLVED, source.placeholderStatus()); - assertEquals(new PortId("value"), source.ports().getFirst().id()); - assertEquals("number", source.ports().getFirst().valueType()); - - NodeModel unavailable = program.rootGraph().node(TARGET_ID).orElseThrow(); - assertEquals("addon_that_is_gone:receiver", unavailable.typeId()); - assertEquals(PlaceholderStatus.MISSING_TYPE, unavailable.placeholderStatus()); - assertEquals("preserve me", unavailable.rawTag().getString("privateAddonPayload")); - assertEquals(17, unavailable.state().getInt("privateCounter")); - assertEquals(new PortId("payload"), unavailable.ports().getFirst().id()); - - ConnectionModel connection = program.rootGraph().connections().getFirst(); - assertEquals(CONNECTION_ID, connection.id()); - assertEquals(new PortId("value"), connection.sourcePort()); - assertEquals(new PortId("payload"), connection.targetPort()); - assertEquals(List.of(new Waypoint(12.0D, -4.0D)), connection.waypoints()); - assertEquals("wire-private", connection.rawTag().getString("addonWireData")); - - assertEquals("root-private", program.metadata() - .getCompound("legacyRootExtras") - .getString("unrecognizedRootData")); - assertEquals(1, program.rootGraph().sections().size()); - assertEquals(96, program.rootGraph().sections().getFirst().width()); - assertTrue(program.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code().equals("legacy_type_renamed"))); - assertTrue(program.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code().equals("missing_node_type"))); - - ProgramCodec.DecodeResult roundTrip = - ProgramCodec.decode(ProgramCodec.encode(program), type -> type.equals("computed:constant")); - assertFalse(roundTrip.migrated()); - assertEquals(ComputedProgram.FORMAT_VERSION, roundTrip.sourceVersion()); - NodeModel restoredMissing = roundTrip.program().rootGraph().node(TARGET_ID).orElseThrow(); - assertEquals(PlaceholderStatus.MISSING_TYPE, restoredMissing.placeholderStatus()); - assertEquals("preserve me", restoredMissing.rawTag().getString("privateAddonPayload")); - assertEquals(new PortId("payload"), restoredMissing.ports().getFirst().id()); - assertEquals(new PortId("value"), roundTrip.program() - .rootGraph() - .connections() - .getFirst() - .sourcePort()); - } - - @Test - void v2RoundTripPreservesVersionedModelFunctionsStateAndStablePortKeys() { - CompoundTag properties = new CompoundTag(); - properties.putString("mode", "latched"); - CompoundTag state = new CompoundTag(); - state.putLong("lastTick", 90210L); - CompoundTag portData = new CompoundTag(); - portData.putDouble("default", 2.5D); - NodeModel source = new NodeModel( - SOURCE_ID, - "computed:source", - "computed:source", - "Source", - -20, - 40, - properties, - state, - List.of(new PortModel(new PortId("signal.current"), Direction.OUTPUT, "number", "Signal", portData)), - PlaceholderStatus.RESOLVED, - tagged("nodeRaw", "source-private")); - NodeModel target = new NodeModel( - TARGET_ID, - "computed:sink", - "computed:sink", - "Sink", - 80, - 40, - new CompoundTag(), - new CompoundTag(), - List.of(new PortModel(new PortId("signal.input"), Direction.INPUT, "number", "Signal", new CompoundTag())), - PlaceholderStatus.RESOLVED, - new CompoundTag()); - ConnectionModel connection = new ConnectionModel( - CONNECTION_ID, - SOURCE_ID, - new PortId("signal.current"), - TARGET_ID, - new PortId("signal.input"), - List.of(new Waypoint(1.25D, 2.5D), new Waypoint(4.0D, 8.0D)), - tagged("wireRaw", "curve-a")); - SectionModel section = new SectionModel( - uuid(104), "Control", -40, 10, 180, 90, 0x44332211, 3, tagged("sectionRaw", "locked")); - GraphModel graph = new GraphModel( - GRAPH_ID, - List.of(target, source), - List.of(connection), - List.of(section), - tagged("graphMeta", "root"), - tagged("graphRaw", "raw-root")); - GraphModel functionGraph = new GraphModel(uuid(105), List.of(source), List.of(), List.of()); - FunctionModel function = new FunctionModel( - uuid(106), - "Normalize", - functionGraph, - tagged("functionMeta", "library"), - tagged("functionRaw", "keep")); - ProgramDiagnostic diagnostic = new ProgramDiagnostic( - Severity.WARNING, - "fixture_warning", - "fixture", - GRAPH_ID, - SOURCE_ID, - null, - tagged("detail", "attached")); - ComputedProgram original = new ComputedProgram( - 44L, graph, List.of(function), List.of(diagnostic), tagged("programMeta", "v2")); - - CompoundTag encoded = ProgramCodec.write(original); - ProgramCodec.DecodeResult result = ProgramCodec.decode( - encoded, Set.of("computed:source", "computed:sink")::contains); - ComputedProgram restored = result.program(); - - assertFalse(result.migrated()); - assertEquals(2, encoded.getInt("formatVersion")); - assertEquals(44L, restored.revision()); - assertEquals(GRAPH_ID, restored.rootGraph().id()); - assertEquals(List.of(TARGET_ID, SOURCE_ID), restored.rootGraph().nodes().stream() - .map(NodeModel::id) - .toList()); - NodeModel restoredSource = restored.rootGraph().node(SOURCE_ID).orElseThrow(); - assertEquals("latched", restoredSource.properties().getString("mode")); - assertEquals(90210L, restoredSource.state().getLong("lastTick")); - assertEquals("signal.current", restoredSource.ports().getFirst().id().value()); - assertEquals(2.5D, restoredSource.ports().getFirst().data().getDouble("default")); - assertEquals(connection.waypoints(), restored.rootGraph().connections().getFirst().waypoints()); - assertEquals("locked", restored.rootGraph().sections().getFirst().rawTag().getString("sectionRaw")); - assertEquals("Normalize", restored.functions().getFirst().name()); - assertEquals(uuid(105), restored.functions().getFirst().graph().id()); - assertEquals("library", restored.functions().getFirst().metadata().getString("functionMeta")); - assertEquals("fixture_warning", restored.diagnostics().getFirst().code()); - assertEquals("attached", restored.diagnostics().getFirst().details().getString("detail")); - assertEquals("v2", restored.metadata().getString("programMeta")); - } - - @Test - void migratesLegacyFunctionBodiesAndPreservesFunctionSpecificData() { - UUID functionId = uuid(200); - UUID functionGraphId = uuid(201); - UUID functionNodeId = uuid(202); - - CompoundTag functionNode = legacyNode(functionNodeId, "websnodelib:constant", "Function source"); - ListTag outputs = new ListTag(); - outputs.add(legacyPort("result", "number", "Result")); - functionNode.put("outputs", outputs); - ListTag functionNodes = new ListTag(); - functionNodes.add(functionNode); - - CompoundTag functionBody = new CompoundTag(); - functionBody.putUUID("id", functionGraphId); - functionBody.put("nodes", functionNodes); - functionBody.put("conns", new ListTag()); - functionBody.put("sections", new ListTag()); - - CompoundTag legacyFunction = new CompoundTag(); - legacyFunction.putUUID("Id", functionId); - legacyFunction.putString("Name", "Legacy normalize"); - legacyFunction.put("Body", functionBody); - legacyFunction.putString("addonFunctionPayload", "keep-function-data"); - ListTag functions = new ListTag(); - functions.add(legacyFunction); - - CompoundTag legacy = legacyBundle(); - legacy.put("ComputerFunctions", functions); - ComputedProgram program = ProgramCodec.decode( - legacy, type -> type.equals("computed:constant")) - .program(); - - assertEquals(1, program.functions().size()); - FunctionModel function = program.functions().getFirst(); - assertEquals(functionId, function.id()); - assertEquals("Legacy normalize", function.name()); - assertEquals(functionGraphId, function.graph().id()); - assertEquals("computed:constant", function.graph().nodes().getFirst().typeId()); - assertEquals("websnodelib:constant", function.graph().nodes().getFirst().originalTypeId()); - assertEquals("keep-function-data", function.metadata().getString("addonFunctionPayload")); - - CompoundTag bridged = ProgramCodec.toLegacyBundleTag(program); - CompoundTag bridgedFunction = bridged.getList("ComputerFunctions", Tag.TAG_COMPOUND).getCompound(0); - assertEquals(functionId, bridgedFunction.getUUID("Id")); - assertEquals("Legacy normalize", bridgedFunction.getString("Name")); - assertEquals("keep-function-data", bridgedFunction.getString("addonFunctionPayload")); - assertEquals("computed:constant", bridgedFunction - .getCompound("Body") - .getList("nodes", Tag.TAG_COMPOUND) - .getCompound(0) - .getString("typeId")); - } - - private static CompoundTag legacyBundle() { - CompoundTag source = legacyNode(SOURCE_ID, "websnodelib:constant", "Source"); - ListTag sourceOutputs = new ListTag(); - sourceOutputs.add(legacyPort("value", "number", "Value")); - source.put("outputs", sourceOutputs); - - CompoundTag target = legacyNode(TARGET_ID, "addon_that_is_gone:receiver", "Missing receiver"); - target.putString("privateAddonPayload", "preserve me"); - target.putInt("privateCounter", 17); - ListTag targetInputs = new ListTag(); - targetInputs.add(legacyPort("payload", "number", "Payload")); - target.put("inputs", targetInputs); - - ListTag nodes = new ListTag(); - nodes.add(source); - nodes.add(target); - - CompoundTag wire = new CompoundTag(); - wire.putUUID("id", CONNECTION_ID); - wire.putUUID("src", SOURCE_ID); - wire.putInt("srcP", 0); - wire.putString("sourcePort", "value"); - wire.putUUID("tgt", TARGET_ID); - wire.putInt("tgtP", 0); - wire.putString("targetPort", "payload"); - wire.putString("addonWireData", "wire-private"); - CompoundTag waypoint = new CompoundTag(); - waypoint.putDouble("x", 12.0D); - waypoint.putDouble("y", -4.0D); - ListTag waypoints = new ListTag(); - waypoints.add(waypoint); - wire.put("wps", waypoints); - ListTag wires = new ListTag(); - wires.add(wire); - - CompoundTag section = new CompoundTag(); - section.putUUID("id", uuid(107)); - section.putString("name", "Legacy section"); - section.putInt("x", -10); - section.putInt("y", -20); - section.putInt("w", 96); - section.putInt("h", 64); - ListTag sections = new ListTag(); - sections.add(section); - - CompoundTag graph = new CompoundTag(); - graph.putUUID("id", GRAPH_ID); - graph.put("nodes", nodes); - graph.put("conns", wires); - graph.put("sections", sections); - - CompoundTag bundle = new CompoundTag(); - bundle.put("ComputerGraph", graph); - bundle.put("ComputerFunctions", new ListTag()); - bundle.putString("unrecognizedRootData", "root-private"); - return bundle; - } - - private static CompoundTag legacyNode(UUID id, String type, String title) { - CompoundTag node = new CompoundTag(); - node.putUUID("id", id); - node.putString("typeId", type); - node.putString("title", title); - node.putInt("x", 10); - node.putInt("y", 20); - node.put("inputs", new ListTag()); - node.put("outputs", new ListTag()); - return node; - } - - private static CompoundTag legacyPort(String key, String valueType, String label) { - CompoundTag port = new CompoundTag(); - port.putString("portKey", key); - port.putString("dataType", valueType); - port.putString("name", label); - return port; - } - - private static CompoundTag tagged(String key, String value) { - CompoundTag tag = new CompoundTag(); - tag.putString(key, value); - return tag; - } - - private static UUID uuid(long suffix) { - return new UUID(0L, suffix); - } -} diff --git a/src/test/java/dev/propulsionteam/computed/node/runtime/GraphAnalysisTest.java b/src/test/java/dev/propulsionteam/computed/node/runtime/GraphAnalysisTest.java deleted file mode 100644 index 39aaa76..0000000 --- a/src/test/java/dev/propulsionteam/computed/node/runtime/GraphAnalysisTest.java +++ /dev/null @@ -1,175 +0,0 @@ -package dev.propulsionteam.computed.node.runtime; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.propulsionteam.computed.node.program.ConnectionModel; -import dev.propulsionteam.computed.node.program.GraphModel; -import dev.propulsionteam.computed.node.program.NodeModel; -import dev.propulsionteam.computed.node.program.NodeModel.PlaceholderStatus; -import dev.propulsionteam.computed.node.program.PortId; -import dev.propulsionteam.computed.node.program.PortModel; -import dev.propulsionteam.computed.node.program.PortModel.Direction; -import java.util.List; -import java.util.UUID; -import net.minecraft.nbt.CompoundTag; -import org.junit.jupiter.api.Test; - -class GraphAnalysisTest { - private static final PortId INPUT = new PortId("input"); - private static final PortId OUTPUT = new PortId("output"); - - @Test - void topologicalOrderIsStableAcrossInsertionOrderAndUsesUuidAsTieBreaker() { - NodeModel first = node(1, "computed:pure"); - NodeModel second = node(2, "computed:pure"); - NodeModel join = node(3, "computed:pure"); - ConnectionModel firstToJoin = connection(11, first, join); - ConnectionModel secondToJoin = connection(12, second, join); - - GraphModel scrambled = graph( - List.of(join, second, first), - List.of(secondToJoin, firstToJoin)); - GraphModel reversed = graph( - List.of(first, join, second), - List.of(firstToJoin, secondToJoin)); - - List expected = List.of(first.id(), second.id(), join.id()); - assertEquals(expected, GraphAnalysis.analyze(scrambled, ignored -> false).topologicalOrder()); - assertEquals(expected, GraphAnalysis.analyze(reversed, ignored -> false).topologicalOrder()); - } - - @Test - void combinationalCycleDisablesOnlyItsStronglyConnectedComponent() { - NodeModel first = node(1, "computed:pure"); - NodeModel second = node(2, "computed:pure"); - NodeModel independent = node(3, "computed:pure"); - ConnectionModel forward = connection(11, first, second); - ConnectionModel back = connection(12, second, first); - - GraphAnalysis.AnalysisResult result = GraphAnalysis.analyze( - graph(List.of(independent, second, first), List.of(back, forward)), - ignored -> false); - - assertEquals(List.of(independent.id()), result.topologicalOrder()); - assertEquals(List.of(List.of(first.id(), second.id())), result.combinationalCycles()); - assertEquals(java.util.Set.of(first.id(), second.id()), result.disabledNodes()); - assertEquals(java.util.Set.of(forward.id(), back.id()), result.invalidConnections()); - assertFalse(result.executable(first.id())); - assertTrue(result.executable(independent.id())); - assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code().equals("combinational_cycle"))); - } - - @Test - void stateBoundaryBreaksFeedbackWhileRemainingScheduledAfterItsCurrentInput() { - NodeModel pure = node(1, "computed:pure"); - NodeModel memory = node(2, "computed:memory"); - - GraphAnalysis.AnalysisResult result = GraphAnalysis.analyze( - graph( - List.of(memory, pure), - List.of(connection(11, pure, memory), connection(12, memory, pure))), - type -> type.equals("computed:memory")); - - assertTrue(result.combinationalCycles().isEmpty()); - assertTrue(result.disabledNodes().isEmpty()); - assertTrue(result.invalidConnections().isEmpty()); - assertEquals(List.of(pure.id(), memory.id()), result.topologicalOrder()); - } - - @Test - void placeholderAndInvalidStablePortDoNotDisableIndependentValidNodes() { - NodeModel placeholder = new NodeModel( - uuid(1), - "missing:addon_node", - "missing:addon_node", - "Unavailable", - 0, - 0, - new CompoundTag(), - new CompoundTag(), - ports(), - PlaceholderStatus.MISSING_TYPE, - new CompoundTag()); - NodeModel valid = node(2, "computed:pure"); - ConnectionModel invalidPort = new ConnectionModel( - uuid(11), - valid.id(), - new PortId("renamed-output"), - placeholder.id(), - INPUT, - List.of(), - new CompoundTag()); - - GraphAnalysis.AnalysisResult result = - GraphAnalysis.analyze(graph(List.of(valid, placeholder), List.of(invalidPort)), ignored -> false); - - assertEquals(java.util.Set.of(placeholder.id()), result.disabledNodes()); - assertEquals(java.util.Set.of(invalidPort.id()), result.invalidConnections()); - assertEquals(List.of(valid.id()), result.topologicalOrder()); - assertTrue(result.diagnostics().stream() - .anyMatch(diagnostic -> diagnostic.code().equals("placeholder_node_disabled"))); - assertTrue(result.diagnostics().stream() - .anyMatch(diagnostic -> diagnostic.code().equals("invalid_connection_port"))); - } - - @Test - void numberToStringConnectionMatchesRuntimeConversionPolicy() { - NodeModel numberSource = nodeWithTypes(1, "number", "number"); - NodeModel stringTarget = nodeWithTypes(2, "string", "string"); - - GraphAnalysis.AnalysisResult result = GraphAnalysis.analyze( - graph(List.of(stringTarget, numberSource), List.of(connection(11, numberSource, stringTarget))), - ignored -> false); - - assertTrue(result.invalidConnections().isEmpty()); - assertEquals(List.of(numberSource.id(), stringTarget.id()), result.topologicalOrder()); - assertTrue(GraphAnalysis.compatibleValueTypes("number", "string")); - assertFalse(GraphAnalysis.compatibleValueTypes("string", "number")); - } - - private static GraphModel graph(List nodes, List connections) { - return new GraphModel(uuid(100), nodes, connections, List.of()); - } - - private static NodeModel node(long id, String type) { - return nodeWithTypes(id, "number", "number", type); - } - - private static NodeModel nodeWithTypes(long id, String inputType, String outputType) { - return nodeWithTypes(id, inputType, outputType, "computed:pure"); - } - - private static NodeModel nodeWithTypes(long id, String inputType, String outputType, String type) { - return new NodeModel( - uuid(id), - type, - type, - type, - 0, - 0, - new CompoundTag(), - new CompoundTag(), - List.of( - new PortModel(INPUT, Direction.INPUT, inputType, "Input", new CompoundTag()), - new PortModel(OUTPUT, Direction.OUTPUT, outputType, "Output", new CompoundTag())), - PlaceholderStatus.RESOLVED, - new CompoundTag()); - } - - private static List ports() { - return List.of( - new PortModel(INPUT, Direction.INPUT, "number", "Input", new CompoundTag()), - new PortModel(OUTPUT, Direction.OUTPUT, "number", "Output", new CompoundTag())); - } - - private static ConnectionModel connection(long id, NodeModel source, NodeModel target) { - return new ConnectionModel( - uuid(id), source.id(), OUTPUT, target.id(), INPUT, List.of(), new CompoundTag()); - } - - private static UUID uuid(long suffix) { - return new UUID(0L, suffix); - } -} diff --git a/src/test/java/dev/propulsionteam/computed/persistence/ProgramV3CodecTest.java b/src/test/java/dev/propulsionteam/computed/persistence/ProgramV3CodecTest.java new file mode 100644 index 0000000..d120561 --- /dev/null +++ b/src/test/java/dev/propulsionteam/computed/persistence/ProgramV3CodecTest.java @@ -0,0 +1,131 @@ +package dev.propulsionteam.computed.persistence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.propulsionteam.computed.graph.ComputedGraph; +import dev.propulsionteam.computed.graph.ComputedProgramV3; +import dev.propulsionteam.computed.graph.GraphConnection; +import dev.propulsionteam.computed.graph.GraphNode; +import dev.propulsionteam.computed.graph.GraphPoint; +import dev.propulsionteam.computed.graph.LuaDefinitionSource; +import dev.propulsionteam.computed.graph.PortDirection; +import dev.propulsionteam.computed.graph.PortSnapshot; +import dev.propulsionteam.computed.lua.node.ConnectionType; +import dev.propulsionteam.computed.lua.runtime.LuaStateCodec; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.nbt.CompoundTag; +import org.junit.jupiter.api.Test; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +class ProgramV3CodecTest { + @Test + void roundTripsFormatThreeGraphLibraryPortSnapshotsAndState() { + UUID graphId = UUID.randomUUID(); + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + LuaDefinitionSource definition = + LuaDefinitionSource.embedded(1, "example:source", source("example:source")); + GraphNode source = new GraphNode( + sourceId, + definition.id(), + definition.hash(), + -40, + 12, + List.of(new PortSnapshot("value", PortDirection.OUTPUT, ConnectionType.NUMBER, "Value")), + Map.of()); + GraphNode target = new GraphNode( + targetId, + "computed:display", + "bundled-hash", + 80, + 12, + List.of(new PortSnapshot("value", PortDirection.INPUT, ConnectionType.NUMBER, "Value")), + Map.of()); + GraphConnection connection = new GraphConnection( + UUID.randomUUID(), + sourceId, + "value", + targetId, + "value", + List.of(new GraphPoint(2.5, 9.0))); + LuaTable stateTable = new LuaTable(); + stateTable.set("count", LuaValue.valueOf(7)); + CompoundTag state = new LuaStateCodec().encode(stateTable); + ComputedProgramV3 original = new ComputedProgramV3( + 14, + new ComputedGraph(graphId, List.of(source, target), List.of(connection)), + Map.of(definition.id(), definition), + Map.of(sourceId, state), + tagged("name", "fixture")); + + CompoundTag encoded = ProgramV3Codec.encode(original); + ProgramV3Codec.LoadResult result = ProgramV3Codec.decode(encoded, "1,2,3", null); + + assertFalse(result.discardedLegacy()); + assertEquals(3, encoded.getInt("formatVersion")); + assertEquals(14, result.program().revision()); + assertEquals(graphId, result.program().rootGraph().id()); + assertEquals(definition.hash(), result.program().library().get(definition.id()).hash()); + assertEquals("value", result.program().rootGraph().nodes().getFirst().ports().getFirst().id()); + assertEquals( + 7, + new LuaStateCodec() + .decode(result.program().persistentState().get(sourceId)) + .get("count") + .toint()); + assertEquals(List.of(new GraphPoint(2.5, 9.0)), result.program().rootGraph().connections().getFirst().waypoints()); + assertEquals("fixture", result.program().metadata().getString("name")); + } + + @Test + void discardsLegacyProgramsWithoutBackupAndReportsPosition() { + CompoundTag legacy = new CompoundTag(); + legacy.putInt("formatVersion", 2); + legacy.putString("legacyPayload", "discard me"); + List warnings = new ArrayList<>(); + + ProgramV3Codec.LoadResult result = + ProgramV3Codec.decode(legacy, "BlockPos{x=4,y=70,z=-8}", warnings::add); + + assertTrue(result.discardedLegacy()); + assertEquals(2, result.sourceVersion()); + assertTrue(result.program().rootGraph().nodes().isEmpty()); + assertTrue(result.program().library().isEmpty()); + assertEquals(1, warnings.size()); + assertTrue(warnings.getFirst().contains("BlockPos{x=4,y=70,z=-8}")); + assertFalse(ProgramV3Codec.encode(result.program()).contains("legacyPayload")); + } + + @Test + void rejectsFutureFormatsHashTamperingAndLegacyClipboardPayloads() { + CompoundTag future = new CompoundTag(); + future.putInt("formatVersion", 4); + + assertThrows(IllegalArgumentException.class, () -> ProgramV3Codec.decode(future, "origin", null)); + assertThrows( + IllegalArgumentException.class, + () -> new LuaDefinitionSource(1, "example:bad", source("example:bad"), "wrong", null)); + assertThrows(IllegalArgumentException.class, () -> LuaDefinitionClipboard.importSource("CMP2payload")); + } + + private static String source(String id) { + return "local node = computed.node(1, \"" + + id + + "\", \"Fixture\")\n" + + "node:on_run(function(ctx) end)\n" + + "return node\n"; + } + + private static CompoundTag tagged(String key, String value) { + CompoundTag tag = new CompoundTag(); + tag.putString(key, value); + return tag; + } +} diff --git a/src/test/java/net/neoforged/api/distmarker/Dist.java b/src/test/java/net/neoforged/api/distmarker/Dist.java new file mode 100644 index 0000000..f78ff97 --- /dev/null +++ b/src/test/java/net/neoforged/api/distmarker/Dist.java @@ -0,0 +1,10 @@ +package net.neoforged.api.distmarker; + +public enum Dist { + CLIENT, + DEDICATED_SERVER; + + public boolean isDedicatedServer() { + return this == DEDICATED_SERVER; + } +} diff --git a/src/test/java/net/neoforged/fml/loading/FMLEnvironment.java b/src/test/java/net/neoforged/fml/loading/FMLEnvironment.java new file mode 100644 index 0000000..3865b55 --- /dev/null +++ b/src/test/java/net/neoforged/fml/loading/FMLEnvironment.java @@ -0,0 +1,9 @@ +package net.neoforged.fml.loading; + +import net.neoforged.api.distmarker.Dist; + +public final class FMLEnvironment { + public static Dist dist = Dist.DEDICATED_SERVER; + + private FMLEnvironment() {} +} diff --git a/wiki/Commands.md b/wiki/Commands.md deleted file mode 100644 index c193a63..0000000 --- a/wiki/Commands.md +++ /dev/null @@ -1,60 +0,0 @@ -# Commands - -Computed registers a single command tree under `/computed`. - ---- - -## `/computed reload` - -Reloads all custom node JSON files from `config/computed/nodes/` at runtime. No game restart is needed. - -**Usage:** - -``` -/computed reload -``` - -**Output (chat message):** - -``` -Custom nodes reloaded: loaded=3, skipped=0, warnings=0, errors=0 -``` - -| Field | Description | -|---|---| -| `loaded` | Number of nodes successfully registered this reload | -| `skipped` | Number of files/nodes skipped (ID conflicts, duplicate IDs, etc.) | -| `warnings` | Non-fatal issues logged (e.g. unknown optional fields) | -| `errors` | Fatal parse or validation errors — nodes in these files were not loaded | - -**Return value:** Returns `1` if there were no errors, `0` if any errors occurred (for use in command blocks or other command chaining). - ---- - -## Reload behavior - -- All previously loaded custom nodes from the last reload are **replaced** by the new set. -- Built-in nodes (defined in Java) are never affected by reload. -- If a JSON file has a parse error, that file is skipped entirely — other valid files in the same run still load. -- If a node ID conflicts with a built-in node, a warning is logged and the file is skipped. -- The `config/computed/nodes/` directory is created automatically if it does not exist. - ---- - -## Server-side note - -The command is registered on the server side. In single-player it runs in the integrated server context. On a dedicated server, any operator can run it. - ---- - -## Log output - -In addition to the chat message, Computed logs detailed per-file results to the game log at `INFO` level. Errors are logged at `ERROR` level and warnings at `WARN` level. Check `logs/latest.log` if you need to diagnose a failed reload. - -Example log lines: - -``` -[custom-nodes] Loaded computed:my_node from my_node.json -[custom-nodes] WARN Skipped computed:builtin_conflict — ID already registered -[custom-nodes] reload complete: loaded=2, skipped=1, warnings=1, errors=0, root=.../config/computed/nodes -``` diff --git a/wiki/Custom-Nodes.md b/wiki/Custom-Nodes.md deleted file mode 100644 index e77bb53..0000000 --- a/wiki/Custom-Nodes.md +++ /dev/null @@ -1,128 +0,0 @@ -# Custom Nodes - -Custom nodes let you define your own node graph nodes entirely in JSON — no Java required. - ---- - -## File location - -Place `.json` files (any depth of subdirectories) inside: - -``` -config/computed/nodes/ -``` - -The loader scans **recursively** for `*.json`. Files with other extensions (e.g. `.md`) are silently ignored. - ---- - -## Reload without restarting - -``` -/computed reload -``` - -Reloads all JSON files from the nodes folder at runtime. The chat prints a summary: - -``` -Custom nodes reloaded: loaded=3, skipped=0, warnings=0, errors=0 -``` - -A node whose ID conflicts with a built-in or already-registered node is skipped with a warning. - ---- - -## Full JSON schema - -```json -{ - "id": "computed:my_node", - "label": "My Node", - "menuPath": ["Custom", "Math"], - "inputs": [ - { "name": "A", "type": "number", "color": "#00FF88" }, - { "name": "Tag", "type": "string", "color": "#FFC830" } - ], - "outputs": [ - { "name": "Sum", "type": "number", "color": "#FF5555", "expression": "A + gain" }, - { "name": "Label", "type": "string", "color": "#FFC830", "expression": "concat(Tag, \" = \", str(A))" } - ], - "constants": { - "gain": 2.0 - }, - "state": [ - { "name": "count", "init": 0, "update": "count + 1" } - ] -} -``` - ---- - -## Top-level fields - -| Field | Type | Required | Description | -|---|---|---|---| -| `id` | string | ✓ | `namespace:path`. Must be unique across all nodes. | -| `label` | string | ✓ | Display name shown on the node tile. | -| `menuPath` | string[] | | Category path in the Add Node menu. Defaults to `["Custom"]`. | -| `inputs` | pin[] | | Input pins. May be omitted if the node has no inputs. | -| `outputs` | pin[] | ✓ | Output pins. At least one required. | -| `constants` | object | | Named numeric constants available in all expressions on this node. | -| `state` | state[] | | Persistent per-tick state variables. See [Persistent State](Persistent-State). | - ---- - -## Pin spec (`inputs` and `outputs`) - -| Field | Type | Default | Description | -|---|---|---|---| -| `name` | string | ✓ | Pin label. Must be unique across **all** pins on this node (inputs + outputs). | -| `type` | `"number"` \| `"string"` | `"number"` | Data type carried by this pin. | -| `color` | `"#RRGGBB"` or `"#AARRGGBB"` | auto | Pin accent colour in the UI. | -| `expression` | string | ✓ (outputs only) | Expression evaluated each tick to produce this output's value. See [Expressions](Expressions). | - -Input pins do **not** have an `expression` field — their value comes from whatever is wired into them. - ---- - -## ID naming rules - -- Format: `namespace:path` -- Both segments may contain lowercase letters, digits, `_`, `-`, `.` -- Must be globally unique — conflicts with built-in nodes cause the file to be skipped -- Recommended: use your own namespace (e.g. `mypack:node_name`) to avoid clashes - ---- - -## menuPath - -Controls where the node appears in the Add Node menu. Each string is a nested category level. - -```json -"menuPath": ["Automation", "Sensors"] -``` - -Omitting `menuPath` places the node under `["Custom"]`. - ---- - -## constants - -A JSON object mapping names to fixed numeric values. Constants are available by name in all expressions on the node. - -```json -"constants": { - "pi": 3.14159, - "threshold": 0.5 -} -``` - -Constants cannot be changed at runtime and are not saved to NBT. - ---- - -## See also - -- [Expressions](Expressions) — expression syntax -- [Persistent State](Persistent-State) — the `state` array -- [Examples](Examples) — complete working node files diff --git a/wiki/Examples.md b/wiki/Examples.md deleted file mode 100644 index 3d033a3..0000000 --- a/wiki/Examples.md +++ /dev/null @@ -1,234 +0,0 @@ -# Examples - -All examples below can be saved as `.json` files in `config/computed/nodes/` and loaded with `/computed reload`. - ---- - -## Simple addition - -Two number inputs, one output that adds them. - -```json -{ - "id": "computed:example_add", - "label": "Example Add", - "menuPath": ["Custom", "Examples"], - "inputs": [ - { "name": "A", "color": "#00FF88" }, - { "name": "B", "color": "#00FF88" } - ], - "outputs": [ - { "name": "Result", "color": "#FF5555", "expression": "A + B" } - ], - "constants": { - "bias": 0.0 - } -} -``` - ---- - -## Tick counter with reset - -Uses [persistent state](Persistent-State). Counts ticks. A `> 0.5` signal on `Reset` zeroes the counter. - -```json -{ - "id": "computed:example_counter", - "label": "Tick Counter", - "menuPath": ["Custom", "Examples"], - "inputs": [ - { "name": "Reset", "color": "#FF5555" } - ], - "outputs": [ - { "name": "Count", "color": "#00FF88", "expression": "count" } - ], - "state": [ - { "name": "count", "init": 0, "update": "if(Reset > 0.5, 0, count + 1)" } - ] -} -``` - ---- - -## Sensor label (string output) - -Combines a number and a unit string into a formatted label. - -```json -{ - "id": "computed:example_string_label", - "label": "Sensor Label", - "menuPath": ["Custom", "Examples"], - "inputs": [ - { "name": "Value", "type": "number", "color": "#00FF88" }, - { "name": "Unit", "type": "string", "color": "#FFC830" } - ], - "outputs": [ - { - "name": "Label", - "type": "string", - "color": "#FFC830", - "expression": "concat(str(round(Value)), \" \", Unit)" - } - ] -} -``` - ---- - -## Multi-step clamped difference - -Demonstrates [multi-step expressions](Expressions#multi-step-programs) and [stateful functions](Functions:-Stateful). - -```json -{ - "id": "computed:example_multistep", - "label": "Clamped Difference", - "menuPath": ["Custom", "Examples"], - "inputs": [ - { "name": "A", "color": "#00FF88" }, - { "name": "B", "color": "#FF5555" } - ], - "outputs": [ - { "name": "Diff", "color": "#FFFFFF", "expression": "d = A - B; clamp(d, -10, 10)" }, - { "name": "AbsDiff", "color": "#FFAA00", "expression": "abs(A - B)" }, - { "name": "Rising", "color": "#55FF55", "expression": "rising(A > B)" } - ] -} -``` - ---- - -## Environment sensor - -Reads weather, light, and biome data from the Computer's own position. No inputs needed. - -```json -{ - "id": "computed:example_env_sensor", - "label": "Environment Sensor", - "menuPath": ["Custom", "Examples"], - "outputs": [ - { "name": "Light", "color": "#FFFF55", "expression": "light_level()" }, - { "name": "Raining", "color": "#5599FF", "expression": "is_raining()" }, - { "name": "Thundering", "color": "#9955FF", "expression": "is_thundering()" }, - { "name": "IsDay", "color": "#FFAA00", "expression": "is_day()" }, - { - "name": "Biome", - "type": "string", - "color": "#55FF55", - "expression": "biome_name()" - } - ] -} -``` - ---- - -## Fluid checker - -Reads fluid type, presence, and fill level from the block in front. - -```json -{ - "id": "computed:example_fluid_check", - "label": "Fluid Checker", - "menuPath": ["Custom", "Examples"], - "outputs": [ - { "name": "Present", "color": "#5599FF", "expression": "fluid_present(\"front\")" }, - { "name": "Level", "color": "#55CCFF", "expression": "fluid_level(\"front\")" }, - { - "name": "Type", - "type": "string", - "color": "#FFC830", - "expression": "fluid_type(\"front\")" - } - ] -} -``` - ---- - -## Dirt detector - -Checks whether the block in front is dirt and returns its full ID. - -```json -{ - "id": "computed:dirt_detector", - "label": "Dirt Detector", - "menuPath": ["Custom", "Detectors"], - "outputs": [ - { - "name": "Is Dirt", - "color": "#8B5E3C", - "expression": "block_is(\"minecraft:dirt\", \"front\")" - }, - { - "name": "Block ID", - "type": "string", - "color": "#AAAAAA", - "expression": "block_id(\"front\")" - } - ] -} -``` - ---- - -## Cake detector - -Reads a cake's state via comparator signal. A full cake returns `14`; each slice eaten reduces it by `2`. - -```json -{ - "id": "computed:cake_detector", - "label": "Cake Detector", - "menuPath": ["Custom", "Detectors"], - "outputs": [ - { - "name": "Present", - "color": "#FF5599", - "expression": "comparator(\"front\") > 0" - }, - { - "name": "Signal", - "color": "#FFAA00", - "expression": "comparator(\"front\")" - }, - { - "name": "Slices Left", - "color": "#FF88AA", - "expression": "s = comparator(\"front\"); if(s > 0, s, 0)" - }, - { - "name": "Slices Eaten", - "color": "#994422", - "expression": "s = comparator(\"front\"); if(s > 0, 7 - s, 0)" - } - ] -} -``` - ---- - -## Create kinetic monitor *(requires Create mod)* - -Reads speed, stress, and capacity from the kinetic block directly in front. - -```json -{ - "id": "computed:example_create_kinetic", - "label": "Kinetic Monitor", - "menuPath": ["Custom", "Examples", "Create"], - "outputs": [ - { "name": "IsKinetic", "color": "#FFAA00", "expression": "create_kinetic(\"front\")" }, - { "name": "Speed", "color": "#FF6600", "expression": "create_speed(\"front\")" }, - { "name": "Stress", "color": "#FF3333", "expression": "create_stress(\"front\")" }, - { "name": "Capacity", "color": "#33FF88", "expression": "create_capacity(\"front\")" } - ] -} -``` - -> Place the Computer adjacent to any Create rotating block. Face the Computer toward it and use `"front"`. For a **source** (motor, portable engine), `Capacity` will show the SU generated and `Stress` will be `0`. For a **consumer** (press, millstone), `Stress` will show the SU drawn and `Capacity` will be `0`. diff --git a/wiki/Expressions.md b/wiki/Expressions.md deleted file mode 100644 index 887c317..0000000 --- a/wiki/Expressions.md +++ /dev/null @@ -1,138 +0,0 @@ -# Expressions - -Every output pin on a custom node has an `expression` field. The expression is evaluated **once per tick** to produce the pin's value. - ---- - -## Variables available in expressions - -| Source | How to use | -|---|---| -| Input pin | Use the pin's `name` directly (case-insensitive) | -| Constant | Use the constant's key from the `constants` object | -| State variable | Use the variable's `name` from the `state` array | -| Local variable | Assign with `name = expr` in a multi-step expression | - ---- - -## Data types - -Expressions work with two types: - -- **number** — a 64-bit floating-point value -- **string** — a UTF-16 text value - -Boolean results are represented as numbers: `1.0` = true, `0.0` = false. -The threshold for "truthy" is `> 0.5`. - ---- - -## Operators - -| Operator | Description | -|---|---| -| `+` | Numeric addition, **or string concatenation** if either operand is a string | -| `-` | Subtraction | -| `*` | Multiplication | -| `/` | Division | -| `%` | Modulo | -| `<` `<=` `>` `>=` | Comparison — returns `1.0` or `0.0` | -| `==` `!=` | Equality — returns `1.0` or `0.0` | -| `&&` | Logical AND (short-circuit) | -| `\|\|` | Logical OR (short-circuit) | -| `!` | Logical NOT | -| `(` `)` | Grouping | - -Operator precedence (highest to lowest): `!` → `* / %` → `+ -` → `< <= > >=` → `== !=` → `&&` → `||` - ---- - -## Multi-step programs - -Statements are separated by `;`. The value of the **last statement** is returned as the output value. Intermediate statements are typically assignments. - -``` -"expression": "diff = A - B; abs(diff)" -``` - -Assignment syntax: `name = expression` - -Local variables defined in one statement are visible in all later statements of the **same** expression. They do not persist across ticks (use [`state`](Persistent-State) for that). - -``` -"expression": "lo = min(A, B); hi = max(A, B); hi - lo" -``` - ---- - -## String literals - -Use double-quoted or single-quoted strings inside expressions: - -```json -"expression": "concat(\"Temp: \", str(A), \"°C\")" -``` - -```json -"expression": "concat('Speed: ', str(rpm))" -``` - -Escape sequences inside string literals: - -| Sequence | Character | -|---|---| -| `\"` | Double quote | -| `\'` | Single quote | -| `\\` | Backslash | -| `\n` | Newline | -| `\t` | Tab | - ---- - -## Function calls - -Functions are called with parentheses: `name(arg1, arg2, ...)`. -Functions are case-insensitive. - -``` -"expression": "clamp(A * gain, 0, 100)" -``` - -See the individual function reference pages: - -- [Math Functions](Functions:-Math) -- [String Functions](Functions:-String) -- [Stateful Functions](Functions:-Stateful) -- [World Source Functions](Functions:-World-Sources) -- [Create Source Functions](Functions:-Create-Sources) - ---- - -## Type coercion - -| Coercion | Rule | -|---|---| -| Number → String | `str(x)` or automatic when used with `+` alongside a string | -| String → Number | `num(s)` — parses the string; returns `0` if not a valid number | -| Number → Bool | `> 0.5` | -| Bool → Number | `1.0` (true) or `0.0` (false) | - ---- - -## Examples - -``` -"expression": "A + B" -``` - -``` -"expression": "if(is_raining(), light_level() * 2, light_level())" -``` - -``` -"expression": "d = A - B; sign(d) * clamp(abs(d), 0, 10)" -``` - -``` -"expression": "concat(\"Speed: \", str(round(create_speed(\"front\"))), \" RPM\")" -``` diff --git a/wiki/Functions-Create-Sources.md b/wiki/Functions-Create-Sources.md deleted file mode 100644 index 30a3dbc..0000000 --- a/wiki/Functions-Create-Sources.md +++ /dev/null @@ -1,133 +0,0 @@ -# Create Source Functions - -These functions read kinetic data from the [Create mod](https://www.curseforge.com/minecraft/mc-mods/create). They are only available when Create is installed. All face arguments follow the same [face convention](Functions-World-Sources#face-arguments) as world source functions. - -If Create is not installed, all four functions return `0` silently — no error is thrown. - ---- - -## Functions - -### `create_kinetic(face)` - -Returns `1` if the adjacent block at `face` is a Create kinetic block entity (a block that participates in the stress/rotation network), `0` otherwise. - -| Argument | Type | Description | -|---|---|---| -| `face` | string | Which neighbor to check (`"front"`, `"back"`, `"left"`, `"right"`, `"top"`, `"bottom"`) | - -**Returns:** `0` or `1` - -```json -"expression": "create_kinetic(\"front\")" -``` - ---- - -### `create_speed(face)` - -Returns the rotational speed of the kinetic block at `face` in **RPM** (rotations per minute). The value is signed — negative means the shaft is spinning in the opposite direction. - -| Argument | Type | Description | -|---|---|---| -| `face` | string | Adjacent face to read | - -**Returns:** signed float (RPM). `0` if no kinetic block or Create is absent. - -```json -"expression": "create_speed(\"front\")" -``` - -> Use `abs(create_speed("front"))` if direction does not matter. - ---- - -### `create_stress(face)` - -Returns the **stress (SU) consumed** by the kinetic block at `face`. This is the actual stress units displayed in the Create UI tooltip. - -| Argument | Type | Description | -|---|---|---| -| `face` | string | Adjacent face to read | - -**Returns:** float (SU). `0` if no kinetic block, if the block is a pure source (e.g. a motor), or if Create is absent. - -```json -"expression": "create_stress(\"front\")" -``` - -> **Note:** Sources (motors, engines, waterwheels) return `0` for `create_stress`. Use `create_capacity` for them. - ---- - -### `create_capacity(face)` - -Returns the **stress capacity (SU) generated** by the kinetic block at `face`. This is the maximum SU the block contributes to its kinetic network. - -| Argument | Type | Description | -|---|---|---| -| `face` | string | Adjacent face to read | - -**Returns:** float (SU). `0` if no kinetic block, if the block is a pure consumer (e.g. a mechanical press), or if Create is absent. - -```json -"expression": "create_capacity(\"front\")" -``` - -> **Note:** Consumers (mechanical press, millstone, etc.) return `0` for `create_capacity`. Use `create_stress` for them. - ---- - -## Stress model summary - -Create's stress system distinguishes between *sources* and *consumers*: - -| Block type | `create_stress` | `create_capacity` | -|---|---|---| -| Source (motor, portable engine, waterwheel) | `0` | SU provided | -| Consumer (mechanical press, millstone, fan) | SU drawn | `0` | -| Mixed (some gearboxes) | SU drawn | SU provided | - -The returned values match what the Create UI shows in the block's tooltip — they already account for the block's current speed. - ---- - -## Example node - -```json -{ - "id": "computed:kinetic_monitor", - "label": "Kinetic Monitor", - "menuPath": ["Custom", "Examples", "Create"], - "outputs": [ - { "name": "IsKinetic", "color": "#FFAA00", "expression": "create_kinetic(\"front\")" }, - { "name": "Speed", "color": "#FF6600", "expression": "create_speed(\"front\")" }, - { "name": "Stress", "color": "#FF3333", "expression": "create_stress(\"front\")" }, - { "name": "Capacity", "color": "#33FF88", "expression": "create_capacity(\"front\")" } - ] -} -``` - ---- - -## Network load ratio - -Compute the percentage of a kinetic network's capacity currently in use: - -```json -"expression": "create_stress(\"front\") / max(create_capacity(\"front\"), 1) * 100" -``` - -> Returns `0`–`100`+ (over 100 means overstressed). Cap with `clamp(..., 0, 100)` if desired. - ---- - -## Formatted readout (string output) - -```json -{ - "name": "Status", - "type": "string", - "expression": "format(\"%.0f / %.0f SU @ %.0f RPM\", create_stress(\"front\"), create_capacity(\"front\"), abs(create_speed(\"front\")))" -} -``` diff --git a/wiki/Functions-Math.md b/wiki/Functions-Math.md deleted file mode 100644 index a349c7d..0000000 --- a/wiki/Functions-Math.md +++ /dev/null @@ -1,80 +0,0 @@ -# Math Functions - -All math functions are available in every expression on every custom node. Function names are case-insensitive. - ---- - -## Single-argument functions - -| Function | Returns | Description | -|---|---|---| -| `abs(x)` | number | Absolute value of `x` | -| `sqrt(x)` | number | Square root of `x`. Negative inputs are clamped to `0` before the call. | -| `floor(x)` | number | Round down to nearest integer | -| `ceil(x)` | number | Round up to nearest integer | -| `round(x)` | number | Round to nearest integer (half-even / banker's rounding) | -| `sign(x)` | number | Signum: `-1.0`, `0.0`, or `1.0` | -| `exp(x)` | number | Euler's number raised to the power `x` (eˣ) | -| `sin(x)` | number | Sine of `x` (radians) | -| `cos(x)` | number | Cosine of `x` (radians) | -| `tan(x)` | number | Tangent of `x` (radians) | -| `asin(x)` | number | Arc sine of `x` — result in radians | -| `acos(x)` | number | Arc cosine of `x` — result in radians | -| `atan(x)` | number | Arc tangent of `x` — result in radians | -| `rad(deg)` | number | Convert degrees to radians | -| `deg(rad)` | number | Convert radians to degrees | - ---- - -## Two-argument functions - -| Function | Returns | Description | -|---|---|---| -| `min(a, b)` | number | Smaller of `a` and `b` | -| `max(a, b)` | number | Larger of `a` and `b` | -| `pow(a, b)` | number | `a` raised to the power `b` | -| `atan2(y, x)` | number | Two-argument arc tangent — result in radians | -| `hypot(a, b)` | number | Hypotenuse: `sqrt(a² + b²)` | -| `log(x)` | number | Natural logarithm (base e). Values ≤ 0 are clamped to a small positive epsilon. | -| `log(x, base)` | number | Logarithm of `x` in the given `base`. Returns `0` if `base ≤ 0`. | - ---- - -## Three-argument functions - -| Function | Returns | Description | -|---|---|---| -| `clamp(x, lo, hi)` | number | Restrict `x` to the range `[lo, hi]` | -| `lerp(lo, hi, t)` | number | Linear interpolation between `lo` and `hi` at fraction `t`. `t = 0` → `lo`, `t = 1` → `hi`. No clamping applied to `t`. | - ---- - -## Control flow - -| Function | Returns | Description | -|---|---|---| -| `if(cond, a, b)` | any | Returns `a` if `cond > 0.5`, otherwise `b`. Both `a` and `b` are evaluated before the check. | - ---- - -## Examples - -``` -"expression": "clamp(A * 2, 0, 100)" -``` - -``` -"expression": "lerp(min_val, max_val, t)" -``` - -``` -"expression": "sqrt(pow(dx, 2) + pow(dy, 2))" -``` - -``` -"expression": "if(A > threshold, 1, 0)" -``` - -``` -"expression": "deg(atan2(Y, X))" -``` diff --git a/wiki/Functions-Stateful.md b/wiki/Functions-Stateful.md deleted file mode 100644 index 59ecb29..0000000 --- a/wiki/Functions-Stateful.md +++ /dev/null @@ -1,120 +0,0 @@ -# Stateful Functions - -Stateful functions remember a value between ticks. Each call site is tracked independently — `prev(A)` in one output and `prev(B)` in another output are completely separate slots. - -Call sites are identified by their **position in the expression**, so the same call always refers to the same stored slot. Do not generate call sites dynamically. - -All stored values survive chunk unload and world reload (saved to NBT alongside [state variables](Persistent-State)). - ---- - -## `prev(x)` / `prev(x, default)` - -Returns the value that `x` had **last tick**. On the very first tick (no stored value yet), returns `default`. If `default` is omitted it is `0`. - -| Argument | Type | Description | -|---|---|---| -| `x` | any | The value to observe | -| `default` | any | Value returned on the first tick. Defaults to `0`. | - -**Returns:** the value of `x` from the previous tick. - -```json -"expression": "prev(A)" -``` - -```json -"expression": "prev(light_level(), 15)" -``` - -> **Common use:** compute deltas — `A - prev(A)` gives the change since last tick. - ---- - -## `rising(x)` - -Returns `1` on the **single tick** that `x` transitions from false (`≤ 0.5`) to true (`> 0.5`). Returns `0` every other tick. - -| Argument | Type | Description | -|---|---|---| -| `x` | number | Signal to watch (treated as boolean by the `> 0.5` threshold) | - -**Returns:** `1` on the rising edge, `0` otherwise. - -```json -"expression": "rising(comparator(\"front\") > 0)" -``` - -> **Common use:** detect the moment a redstone signal turns on. - ---- - -## `falling(x)` - -Returns `1` on the **single tick** that `x` transitions from true (`> 0.5`) to false (`≤ 0.5`). Returns `0` every other tick. - -| Argument | Type | Description | -|---|---|---| -| `x` | number | Signal to watch | - -**Returns:** `1` on the falling edge, `0` otherwise. - -```json -"expression": "falling(is_raining())" -``` - -> **Common use:** detect when rain stops. - ---- - -## `changed(x)` - -Returns `1` on any tick that `x` is different from its value last tick. Works with both numbers and strings. - -| Argument | Type | Description | -|---|---|---| -| `x` | any | Value to watch | - -**Returns:** `1` on the tick `x` changes, `0` otherwise. - -```json -"expression": "changed(block_id(\"front\"))" -``` - -```json -"expression": "changed(A)" -``` - -> **Common use:** detect when a signal or block ID changes. - ---- - -## Difference from `state` variables - -| | Stateful functions | `state` variables | -|---|---|---| -| Defined in | Expression (implicit, by call site) | `state` array in JSON | -| Readable from other outputs | No | Yes | -| Writable from expressions | No | Via `update` expression | -| Requires naming | No | Yes | - -Use stateful functions when you only need to compare a value against its previous self. Use [state variables](Persistent-State) when you need to accumulate, count, or share a value across multiple outputs. - ---- - -## Examples - -Detect when a signal pulses (rises then falls): - -```json -"outputs": [ - { "name": "Pulse", "expression": "rising(A > 0)" }, - { "name": "Delta", "expression": "A - prev(A)" } -] -``` - -Detect biome change: - -```json -{ "name": "Biome Changed", "expression": "changed(biome_name())" } -``` diff --git a/wiki/Functions-String.md b/wiki/Functions-String.md deleted file mode 100644 index df79d84..0000000 --- a/wiki/Functions-String.md +++ /dev/null @@ -1,95 +0,0 @@ -# String Functions - -These functions operate on string values. They are available in every expression and are case-insensitive. - -String pins use `"type": "string"` in the JSON schema. Number and string values can be mixed freely — see [Type coercion](Expressions#type-coercion). - ---- - -## Conversion - -| Function | Returns | Description | -|---|---|---| -| `str(x)` | string | Convert a number to its string representation. Integers print without a decimal point. | -| `num(s)` | number | Parse a string to a number. Returns `0` if the string is not a valid number. | - ---- - -## Building strings - -| Function | Returns | Description | -|---|---|---| -| `concat(a, b, ...)` | string | Concatenate one or more values into a single string. Accepts any mix of numbers and strings. Requires at least one argument. | -| `format(fmt, args...)` | string | Java `String.format` style formatting. The first argument is the format string; remaining arguments are substituted. | - -**`format` examples:** - -``` -format("%.2f RPM", speed) → "12.34 RPM" -format("%d / %d", used, max) → "7 / 27" -format("%s %s", "hello", "world") → "hello world" -``` - -Common format specifiers: `%d` (integer), `%f` (float), `%.2f` (float, 2 decimal places), `%s` (string). - ---- - -## Inspection - -| Function | Returns | Description | -|---|---|---| -| `len(s)` | number | Number of characters in `s` | -| `contains(s, sub)` | 0 or 1 | `1` if `s` contains the substring `sub`, otherwise `0` | -| `starts_with(s, prefix)` | 0 or 1 | `1` if `s` starts with `prefix` | -| `ends_with(s, suffix)` | 0 or 1 | `1` if `s` ends with `suffix` | - ---- - -## Transformation - -| Function | Returns | Description | -|---|---|---| -| `upper(s)` | string | Convert `s` to uppercase | -| `lower(s)` | string | Convert `s` to lowercase | -| `substr(s, start)` | string | Substring from index `start` to the end of the string (0-based) | -| `substr(s, start, end)` | string | Substring from index `start` (inclusive) to `end` (exclusive). Both indices are clamped to `[0, len(s)]`. | -| `replace(s, old, new)` | string | Replace **all** occurrences of `old` with `new` in `s` | - -**`substr` index note:** indices are 0-based characters. `substr("hello", 1, 3)` → `"el"`. - ---- - -## The `+` operator with strings - -When either operand of `+` is a string, the result is string concatenation: - -``` -"expression": "\"Speed: \" + str(rpm)" -``` - -This is equivalent to `concat("Speed: ", str(rpm))`. - ---- - -## Examples - -```json -"expression": "concat(\"Temp: \", str(round(A)), \"°C\")" -``` - -```json -"expression": "upper(biome_name())" -``` - -```json -"expression": "if(contains(block_id(\"front\"), \"chest\"), 1, 0)" -``` - -```json -"expression": "format(\"%.1f / %.1f SU\", create_stress(\"front\"), create_capacity(\"front\"))" -``` - -```json -"expression": "substr(block_id(\"front\"), 10)" -``` -> Strips the `minecraft:` namespace prefix (10 characters) from a block ID. diff --git a/wiki/Functions-World-Sources.md b/wiki/Functions-World-Sources.md deleted file mode 100644 index de6cb3d..0000000 --- a/wiki/Functions-World-Sources.md +++ /dev/null @@ -1,160 +0,0 @@ -# World Source Functions - -World source functions read live data from the Minecraft world. They are available in every expression on every custom node. - -All functions return their default value (`0` or `""`) when called outside a server-side world tick (e.g. during expression validation at load time). - ---- - -## Face arguments - -Many functions take a `face` argument that specifies which adjacent block to read. All face strings are **case-insensitive**. - -| Value | Direction | -|---|---| -| `"front"` | The direction the Computer faces | -| `"back"` | Opposite of front | -| `"left"` | Left of the Computer's facing direction | -| `"right"` | Right of the Computer's facing direction | -| `"top"` | Directly above | -| `"bottom"` | Directly below | - -The face is relative to the **Computer block's own facing**. A Computer placed facing north treats `"front"` as north, `"right"` as east, etc. - ---- - -## Environment - -These functions read conditions at the Computer's own position. - -| Function | Returns | Description | -|---|---|---| -| `light_level()` | 0–15 | Maximum of sky light and block light at the Computer's position | -| `light_sky()` | 0–15 | Sky light level at the Computer | -| `light_block()` | 0–15 | Block light level (from torches, glowstone, etc.) at the Computer | -| `is_raining()` | 0 or 1 | `1` if it is currently raining in the dimension | -| `is_thundering()` | 0 or 1 | `1` if a thunderstorm is active | -| `is_day()` | 0 or 1 | `1` if the in-game time is daytime | -| `biome_temp()` | float | Biome base temperature at the Computer's position | -| `biome_downfall()` | float | Biome downfall value (moisture) | -| `biome_name()` | string | Biome registry ID, e.g. `"minecraft:plains"` | - ---- - -## Block - -These functions read the block at an adjacent position. - -| Function | Returns | Description | -|---|---|---| -| `block_id(face)` | string | Registry ID of the block at `face`, e.g. `"minecraft:stone"` | -| `block_is(id, face)` | 0 or 1 | `1` if the block at `face` matches the given registry ID exactly | - -**`block_id` example:** - -```json -"expression": "block_id(\"front\")" -``` - -Returns `"minecraft:air"` for empty air, `"minecraft:water"` for a water source block, etc. - -**`block_is` example:** - -```json -"expression": "block_is(\"minecraft:dirt\", \"front\")" -``` - -Returns `1` if the block directly in front is dirt, `0` otherwise. - -> **Tip:** Use `contains(block_id("front"), "chest")` to match any block whose ID contains the word "chest". - ---- - -## Fluid - -These functions read fluid state at an adjacent position. - -| Function | Returns | Description | -|---|---|---| -| `fluid_present(face)` | 0 or 1 | `1` if any fluid occupies the block at `face` | -| `fluid_level(face)` | 0–8 | Fluid fill amount (8 = full source block, lower = flowing). Returns `0` if no fluid. | -| `fluid_type(face)` | string | `"water"`, `"lava"`, or `""` if no fluid or an unrecognised fluid type. | - -**Example — detect full water tank:** - -```json -"expression": "fluid_present(\"top\") && fluid_level(\"top\") == 8" -``` - ---- - -## Inventory / container - -These functions read inventory data from an adjacent block that exposes an item handler (chest, barrel, hopper, furnace, etc.). - -| Function | Returns | Description | -|---|---|---| -| `container_slots(face)` | int | Total number of slots in the adjacent inventory. Returns `0` if no inventory. | -| `container_count(face)` | int | Total number of items stacked across all slots | -| `container_fill(face)` | 0.0–1.0 | Fill fraction: `total_items / total_capacity`. Each slot's capacity is used if available, otherwise assumed to be 64. Returns `0` if no inventory. | -| `comparator(face)` | 0–15 | Analog comparator output signal of the adjacent block. Falls back to the block's weak redstone signal if the block does not implement `getAnalogOutputSignal`. | - -**Example — trigger when chest is more than half full:** - -```json -"expression": "container_fill(\"front\") > 0.5" -``` - -**Example — read cake slices eaten via comparator:** - -```json -"expression": "7 - comparator(\"front\")" -``` -> A full cake returns `14` from comparator; each slice eaten reduces it by 2. - ---- - -## Examples - -### Environment sensor node - -```json -{ - "id": "computed:env_sensor", - "label": "Environment Sensor", - "outputs": [ - { "name": "Light", "color": "#FFFF55", "expression": "light_level()" }, - { "name": "Raining", "color": "#5599FF", "expression": "is_raining()" }, - { "name": "Thundering", "color": "#9955FF", "expression": "is_thundering()" }, - { "name": "IsDay", "color": "#FFAA00", "expression": "is_day()" }, - { "name": "Biome", "type": "string", "color": "#55FF55", "expression": "biome_name()" } - ] -} -``` - -### Fluid presence checker - -```json -{ - "id": "computed:fluid_check", - "label": "Fluid Checker", - "outputs": [ - { "name": "Present", "color": "#5599FF", "expression": "fluid_present(\"front\")" }, - { "name": "Level", "color": "#55CCFF", "expression": "fluid_level(\"front\")" }, - { "name": "Type", "type": "string", "color": "#FFC830", "expression": "fluid_type(\"front\")" } - ] -} -``` - -### Dirt detector - -```json -{ - "id": "computed:dirt_detector", - "label": "Dirt Detector", - "outputs": [ - { "name": "Is Dirt", "color": "#8B5E3C", "expression": "block_is(\"minecraft:dirt\", \"front\")" }, - { "name": "Block ID", "type": "string", "color": "#AAAAAA", "expression": "block_id(\"front\")" } - ] -} -``` diff --git a/wiki/Home.md b/wiki/Home.md index 5faaf61..3ea2fb6 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -1,31 +1,15 @@ -# Computed — Wiki +# Computed Lua Nodes -**Computed** is a NeoForge 1.21.1 mod that adds programmable in-world computers driven by a visual node graph. Nodes are wired together to compute values each tick and drive outputs (redstone, displays, etc.). +Computed uses sandboxed Lua definitions for bundled, integration, and user-authored nodes. -This wiki covers the **data-driven custom node system**, which lets you define your own nodes in JSON files without writing any Java. - ---- - -## Pages - -| Page | Description | +| Guide | Purpose | |---|---| -| [Custom Nodes](Custom-Nodes) | JSON schema reference — how to define a node | -| [Expressions](Expressions) | Expression syntax, operators, multi-step programs | -| [Math Functions](Functions-Math) | Built-in math functions | -| [String Functions](Functions-String) | Built-in string functions | -| [Stateful Functions](Functions-Stateful) | `prev`, `rising`, `falling`, `changed` | -| [Persistent State](Persistent-State) | Per-node state variables that survive across ticks | -| [World Source Functions](Functions-World-Sources) | Read from the Minecraft world (light, weather, blocks, fluids, inventories) | -| [Create Source Functions](Functions-Create-Sources) | Read kinetic data from the Create mod | -| [Commands](Commands) | In-game commands (`/computed reload`) | -| [Examples](Examples) | Ready-to-use JSON node files | - ---- - -## Quick start - -1. Start your world. The folder `config/computed/nodes/` is created automatically. -2. Drop a `.json` file there (see [Custom Nodes](Custom-Nodes) for the schema). -3. Run `/computed reload` in-game — no restart needed. -4. Open a Computer block and find your node in the **Add Node** menu under its `menuPath`. +| [Authoring](../docs/lua/authoring-guide.md) | Create a Lua node | +| [Lua API](../docs/lua/lua-api-reference.md) | Complete definition and context method reference | +| [Java endpoints](../docs/lua/endpoint-api.md) | Safely expose addon and Minecraft behavior | +| [Types and state](../docs/lua/types-and-state.md) | Connection types and persistent values | +| [Sandbox](../docs/lua/sandbox-and-budgets.md) | Available libraries and instruction limits | +| [Live preview](../docs/lua/live-preview.md) | Editor validation and fixtures | +| [Import/export](../docs/lua/import-export.md) | Embedded library sharing | +| [CC:Tweaked](../docs/lua/computercraft.md) | Optional peripheral bridge | +| [Architecture](../docs/lua/architecture.md) | Runtime and package boundaries | diff --git a/wiki/Persistent-State.md b/wiki/Persistent-State.md deleted file mode 100644 index ff85f30..0000000 --- a/wiki/Persistent-State.md +++ /dev/null @@ -1,125 +0,0 @@ -# Persistent State - -State variables hold a value **across ticks**. They are defined in the `state` array and updated every tick using an expression. Their values are saved to NBT so they survive chunk unload and world reload. - ---- - -## Defining state variables - -Add a `state` array to your node JSON: - -```json -"state": [ - { "name": "count", "init": 0, "update": "count + 1" }, - { "name": "prev", "init": 0.0, "update": "A" } -] -``` - -Each entry is a state variable spec: - -| Field | Type | Required | Description | -|---|---|---|---| -| `name` | string | ✓ | Variable name, accessible in output expressions and other `update` expressions. | -| `init` | number or string | | Initial value when the node is first created. Defaults to `0`. | -| `update` | string | | Expression evaluated **each tick** to produce the next value. | - -If `update` is omitted, the variable keeps its initial value forever (effectively a constant you can pre-set via NBT). - ---- - -## How updates are evaluated - -State updates use a **pre-tick snapshot**: every `update` expression sees the values from the *previous* tick, not values other variables are being updated to this tick. This means updates are independent of each other regardless of their order in the array. - -```json -"state": [ - { "name": "a", "init": 1, "update": "b + 1" }, - { "name": "b", "init": 0, "update": "a + 1" } -] -``` - -Both `a` and `b` see each other's *old* values — there is no dependency ordering issue. - ---- - -## Using state variables in output expressions - -State variable names are available directly in any output expression, alongside input names and constants: - -```json -"outputs": [ - { "name": "Count", "expression": "count" }, - { "name": "Delta", "expression": "A - prev" } -] -``` - ---- - -## String state - -State variables can hold strings. Set `"init"` to a string literal to mark the variable as a string type: - -```json -"state": [ - { "name": "last_biome", "init": "", "update": "biome_name()" } -] -``` - ---- - -## NBT persistence - -All state variable values are written to the Computer block entity's NBT data each time the node is evaluated. They are restored when the chunk is loaded. This makes them suitable for: - -- Counting ticks or events across play sessions -- Remembering the last seen value of a signal -- Accumulating totals over time - ---- - -## Examples - -### Tick counter with reset - -```json -{ - "id": "computed:example_counter", - "label": "Tick Counter", - "inputs": [ - { "name": "Reset", "color": "#FF5555" } - ], - "outputs": [ - { "name": "Count", "color": "#00FF88", "expression": "count" } - ], - "state": [ - { "name": "count", "init": 0, "update": "if(Reset > 0.5, 0, count + 1)" } - ] -} -``` - -### Remember last non-zero value - -```json -"state": [ - { "name": "last", "init": 0, "update": "if(A != 0, A, last)" } -] -``` - -### Track a running maximum - -```json -"state": [ - { "name": "peak", "init": 0, "update": "max(A, peak)" } -] -``` - -### Delta (difference from last tick) - -```json -"state": [ - { "name": "prev_a", "init": 0, "update": "A" } -], -"outputs": [ - { "name": "Delta", "expression": "A - prev_a" } -] -```