diff --git a/docs/REDSTONE_MODULES.md b/docs/REDSTONE_MODULES.md index 1472cdd..b273d81 100644 --- a/docs/REDSTONE_MODULES.md +++ b/docs/REDSTONE_MODULES.md @@ -1,187 +1,57 @@ -# Redstone Modules - -## Status - -This document defines the first implementation boundary for functional redstone builds after CraftDAG v0.2.0. +# Redstone modules: evidence and boundary -Redstone support is a deterministic module system, not arbitrary circuit synthesis. Agents select and configure verified modules; module expanders own exact block positions and states. - -## First target - -- Edition: Minecraft Java Edition -- Version target: `java_1_21` -- First module: `ItemSorterSlice` -- First operating mode: manual filter initialization -- Output path: module config -> validated module -> VoxelPlan -> schematic - -Bedrock behavior, cross-version equivalence, and free-form redstone generation are out of scope. - -## Why modules are separate - -Architectural ComponentPlan components describe bounded shape and composition. Redstone modules additionally require: +CraftDAG has two independently verified MinePilot modules: `ItemSorterSlice` and a Java quasi-connected `PistonDoor`. Their static layouts establish a small shared structural kernel. Their behavior remains module-specific and is verified downstream in the official runtime. -- exact directional block states; -- stable signal input and output locations; -- required air and clearance cells; -- module-specific connectivity invariants; -- version-specific behavior; -- initialization and troubleshooting metadata; -- real-game verification evidence. +## Comparison of the verified implementations -These rules should not be spread across general architectural component validation. +The evidence sources are MinePilot [PR #134](https://github.com/i365dev/MinePilot/pull/134) for ItemSorterSlice and [PR #136](https://github.com/i365dev/MinePilot/pull/136) for PistonDoor (merged MinePilot commit `171926a535389a3446dcc9a1f4ca52f0a38e8afc`). CraftDAG's compact fixtures retain the static topology and four expected orientations, with provenance back to those proofs. -## Proposed public contract +| Concept | Shared structural evidence | Module-specific or external evidence | +| --- | --- | --- | +| Identity/version | Both have a stable module id and version. | The ids and versioning schemes are independent. | +| Canonical layout | Both define integer local coordinates, canonical bounds/facing, and role-tagged authored blocks. | Bounds and authored roles differ. | +| Block states | Both store concrete Minecraft block IDs and exact string properties. | Sorter uses hopper/comparator/repeater/wire/torch states; PistonDoor uses lever/piston states and relies on quasi-connectivity. | +| Orientation | Both rotate local coordinates, bounds, and horizontal `facing` deterministically. | ItemSorter also uses compass-key wire properties, which the kernel rotates. During non-zero turns only those directions and verified invariant properties are accepted; unknown properties fail closed. | +| Generic structure checks | Both use bounded coordinates and non-colliding occupied cells; roles identify authored blocks. | The kernel also requires unique valid role IDs. Required-role relationships and topology invariants remain module-specific. | +| VoxelPlan/provenance | Both lower to ordinary VoxelPlan blocks and carry roles through `sourceNodeId`. Identity/version is reflected in the plan name. | Runtime receipts, artifact digests, references, and behavioral claims remain in downstream evidence. | +| Reference conformance | Both were compared with an external reference. | The sorter has a reference block-table check; the door has an independent six-block reference gate and candidate/reference mechanical check. | +| Runtime semantics | Both have exact external runtime verification evidence. | Sorter initialization, item routing, threshold behavior, and inventory assertions are sorter-specific. Piston movement, reset/pullback, and quasi-connectivity are door-specific. | +| Ports and timing | No repeated public contract was proved. | Ports, signal graphs, timing semantics, and a generic scenario model remain unsupported. | -```ts -type RedstoneFacing = "north" | "east" | "south" | "west"; -type RedstoneVersionTarget = "java_1_21"; +## Proven shared kernel -type RedstoneModulePlan = { - version: "0.1"; - name: string; - versionTarget: RedstoneVersionTarget; - bounds: { width: number; height: number; length: number }; - modules: RedstoneModuleInstance[]; -}; +The internal experimental helper in `packages/core/src/redstone/moduleKernel.ts` accepts a canonical bounded static topology with: -type RedstoneModuleInstance = { - id: string; - type: "ItemSorterSlice"; - placement: { - anchor: { x: number; y: number; z: number }; - facing: RedstoneFacing; - }; - options: { - filterItem: string; - fillerItem?: string; - initializeFilter?: false; - }; -}; -``` +- module identity and version; +- canonical facing and positive integer bounds; +- unique role-tagged blocks at local integer coordinates; +- concrete `minecraft:*` block IDs and exact string properties. -`initializeFilter: true` is intentionally unsupported until VoxelPlan and the schematic exporter have an explicit block-entity contract. +It validates bounds, roles, collisions, and requested facings; rotates coordinates, bounds, horizontal `facing`, and compass-key properties for north/east/south/west; and lowers to the existing VoxelPlan shape. During non-zero rotations, `face`, `enabled`, `extended`, `lit`, `locked`, `mode`, `power`, `powered`, `type`, `waterlogged`, and `delay` are preserved because they occur in the verified fixtures as non-directional state. Any other property fails closed; with zero rotation properties remain unchanged. Block roles are preserved in `sourceNodeId`. Output blocks retain concrete IDs and states. The helper is experimental and is not exported from the public package entry point. -## Expansion rules +Compact static regression fixtures at `packages/core/test/fixtures/redstone/` preserve both canonical topologies and all four expected facing outputs. Tests compare kernel output against these checked-in values; expected transforms are not generated by the code under test. Fixtures contain no GameTest code, server packs, reports, logs, or artifact trees. -Each module has one canonical south-facing local layout. Expansion must: +## Module-specific semantics and verification -1. validate the module config; -2. rotate local coordinates and directional block states for the requested facing; -3. shift the layout to the anchor; -4. validate global bounds and clearance; -5. emit ordinary VoxelPlan blocks; -6. attach module metadata, initialization steps, and diagnostics. +The kernel does not validate circuit behavior or infer topology. Keep these rules with each module and its downstream verifier: -Rotation must use explicit state transforms. Coordinate rotation without updating properties such as `facing`, repeater delay, hopper direction, or comparator mode is invalid. +- **ItemSorterSlice:** filter inventory initialization, transport/overflow path, hopper and comparator relationships, threshold/repeater/torch behavior, and matching/non-matching item routing. +- **PistonDoor:** piston arrangement, moved blocks, activation/reset order, sticky pullback, and Java quasi-connectivity. +- **Verification:** MinePilot's official runtime receipts, state-exact schematic round trips, GameTest assertions, and module-specific reference comparisons remain external evidence. CraftDAG's regression tests prove static compilation and state preservation; they do not rerun Minecraft behavior. -## Module metadata +## Runtime-target boundary -The compiled result needs metadata alongside VoxelPlan: +Current VoxelPlan output contains concrete Minecraft block IDs and block-state properties. It is not edition-neutral. The completed #131 boundary remains in force: shared logical intent may lower to target-specific Redstone topology. The verified PistonDoor depends on Java quasi-connectivity, so its topology cannot be claimed portable to Bedrock. No Java-to-Bedrock mapping or public RuntimeTarget API is introduced here. -```ts -type CompiledRedstoneModule = { - plan: VoxelPlan; - moduleId: string; - moduleType: "ItemSorterSlice"; - versionTarget: "java_1_21"; - verified: boolean; - requiresManualInitialization: boolean; - ports: Array<{ - id: string; - kind: "item_input" | "item_output" | "overflow_output" | "signal_output"; - pos: Vec3; - facing: RedstoneFacing; - }>; - criticalBlocks: Array<{ role: string; pos: Vec3 }>; - initializationSteps: string[]; - troubleshooting: string[]; -}; -``` +## Explicitly deferred -This metadata must not be encoded as decorative blocks or inferred again from the compiled voxel set. +The two proofs do not establish support for: -## Validation stages +- public ports or generic signal inputs/outputs; +- signal graphs, automatic Redstone routing, or arbitrary circuit synthesis; +- timing, tick, or generic runtime-scenario DSLs; +- module composition or repetition semantics; +- Bedrock topology mapping or edition-neutral VoxelPlan; +- a public `RedstoneModulePlan` API. -### Schema validation - -- known module type and version target; -- integer, non-negative anchor; -- supported facing; -- valid namespaced item identifiers; -- unsupported initialization options rejected explicitly. - -### Spatial validation - -- rotated module bounds stay inside the plan; -- required air cells remain clear; -- module instances do not collide unless a declared port connection permits it; -- input, output, and maintenance clearance remain accessible. - -### Static redstone validation - -- filter hopper exists and faces the expected output direction; -- comparator reads the filter hopper; -- repeater/comparator properties match the rotated facing; -- torch lock path and dust path contain every required block; -- output hopper/container path exists; -- all critical positions contain the expected block and state. - -### Verification status - -Unit tests prove deterministic expansion and invariants. They do not prove Minecraft runtime behavior. A module becomes `verified: true` only after an in-game Java 1.21 smoke test is recorded with the fixture version. - -## ItemSorterSlice stages - -### Stage 0: Manual initialization fixture - -- deterministic single-slice layout; -- exact hopper/comparator/repeater/torch/dust states; -- material and layer output; -- schematic export; -- initialization and troubleshooting instructions; -- static invariant tests; -- `requiresManualInitialization: true`. - -### Stage 1: Block entities - -Add a typed block-entity payload to VoxelPlan and schematic export before supporting initialized hopper inventories. The contract must preserve coordinates, NBT payloads, and version targeting through round trips. - -### Stage 2: Arrays - -Build `ItemSorterArray` by repeating verified slices through declared ports and spacing rules. Do not duplicate the single-slice layout by hand. - -## Package layout - -Start inside core while the API is experimental: - -```text -packages/core/src/redstone/ - types.ts - schema.ts - compileRedstoneModulePlan.ts - orientation.ts - modules/itemSorterSlice.ts - validation/itemSorterSlice.ts -``` - -Split a package only when versioning or dependency pressure justifies it. - -## Required tests for the first fixture - -- all four facings produce rotated coordinates and block states; -- repeated compilation is byte-for-byte deterministic; -- every critical invariant has a failing negative test; -- bounds and clearance failures return structured diagnostics; -- material and layer guides include redstone blocks correctly; -- schematic export/import preserves directional block states; -- unsupported automatic initialization fails clearly; -- the fixture includes a documented Java 1.21 in-game smoke-test checklist. - -## Non-goals - -- arbitrary redstone coordinates authored by an LLM; -- a browser tick simulator; -- Bedrock redstone; -- automatic support for every Java version; -- block-entity initialization hidden behind untyped objects; -- claims of working behavior without an in-game verification record. +Add such abstractions only when further real evidence justifies them. Do not infer them from these two modules. diff --git a/packages/core/src/redstone/moduleKernel.ts b/packages/core/src/redstone/moduleKernel.ts new file mode 100644 index 0000000..26a3488 --- /dev/null +++ b/packages/core/src/redstone/moduleKernel.ts @@ -0,0 +1,133 @@ +import type { Vec3, VoxelPlan } from "../types.js"; + +/** Experimental structural core for already-authored, concrete module layouts. */ +export type HorizontalFacing = "north" | "east" | "south" | "west"; + +export interface StaticModuleBlock { + role: string; + pos: Vec3; + block: string; + properties: Record; +} + +export interface StaticModuleDefinition { + identity: { id: string; version: string }; + canonicalFacing: HorizontalFacing; + bounds: Vec3; + blocks: StaticModuleBlock[]; +} + +const FACINGS: HorizontalFacing[] = ["north", "east", "south", "west"]; +const FACING_INDEX = new Map(FACINGS.map((facing, index) => [facing, index])); +// These properties occur in the two verified topologies and do not encode a +// horizontal direction. Unknown properties must not silently survive rotation. +const INVARIANT_PROPERTIES = new Set([ + "delay", + "enabled", + "extended", + "face", + "lit", + "locked", + "mode", + "power", + "powered", + "type", + "waterlogged", +]); + +function assertFacing(value: string): asserts value is HorizontalFacing { + if (!FACING_INDEX.has(value as HorizontalFacing)) throw new Error(`unsupported horizontal facing: ${value}`); +} + +function assertDefinition(module: StaticModuleDefinition): void { + if (!module.identity.id.trim() || !module.identity.version.trim()) throw new Error("module identity and version must be non-empty"); + if (!Array.isArray(module.bounds) || module.bounds.length !== 3 || module.bounds.some((value) => !Number.isInteger(value) || value <= 0)) { + throw new Error("module bounds must be three positive integers"); + } + assertFacing(module.canonicalFacing); + if (!module.blocks.length) throw new Error("module must contain at least one authored block"); + + const roles = new Set(); + const positions = new Set(); + for (const entry of module.blocks) { + if (!/^[a-z][a-z0-9_]*$/.test(entry.role)) throw new Error(`invalid block role: ${entry.role}`); + if (roles.has(entry.role)) throw new Error(`duplicate block role: ${entry.role}`); + roles.add(entry.role); + if (!Array.isArray(entry.pos) || entry.pos.length !== 3 || entry.pos.some((value) => !Number.isInteger(value))) { + throw new Error(`block ${entry.role} position must contain three integers`); + } + const [x, y, z] = entry.pos; + const [width, height, depth] = module.bounds; + if (x < 0 || y < 0 || z < 0 || x >= width || y >= height || z >= depth) { + throw new Error(`block ${entry.role} is outside module bounds`); + } + const coordinate = entry.pos.join(","); + if (positions.has(coordinate)) throw new Error(`occupied-coordinate collision at ${coordinate}`); + positions.add(coordinate); + if (!/^minecraft:[a-z0-9_./-]+$/.test(entry.block)) throw new Error(`invalid concrete Minecraft block id: ${entry.block}`); + for (const [property, value] of Object.entries(entry.properties)) { + if (typeof value !== "string") throw new Error(`block property ${property} on ${entry.role} must be a string`); + if (property === "facing") assertFacingOrVertical(value); + } + } +} + +function assertFacingOrVertical(value: string): void { + if (value === "up" || value === "down") return; + assertFacing(value); +} + +function rotateState(properties: Record, turns: number): Record { + const output: Record = {}; + for (const property of Object.keys(properties).sort()) { + const value = properties[property]; + if (property === "facing") { + output[property] = value === "up" || value === "down" ? value : rotateFacing(value as HorizontalFacing, turns); + } else if (FACING_INDEX.has(property as HorizontalFacing)) { + output[rotateFacing(property as HorizontalFacing, turns)] = value; + } else if (turns === 0 || INVARIANT_PROPERTIES.has(property)) { + output[property] = value; + } else { + throw new Error(`unsupported block-state property during rotation: ${property}`); + } + } + return output; +} + +function rotateFacing(facing: HorizontalFacing, turns: number): HorizontalFacing { + const index = FACING_INDEX.get(facing); + if (index === undefined) throw new Error(`unsupported horizontal facing: ${facing}`); + return FACINGS[(index + turns) % FACINGS.length]; +} + +function rotatePosition(position: Vec3, bounds: Vec3, turns: number): Vec3 { + let [x, , z] = position; + const y = position[1]; + let [width, , depth] = bounds; + for (let turn = 0; turn < turns; turn += 1) { + [x, z] = [depth - 1 - z, x]; + [width, depth] = [depth, width]; + } + return [x, y, z]; +} + +/** Compile concrete authored topology into the existing ordinary VoxelPlan shape. */ +export function compileStaticModule(module: StaticModuleDefinition, facing: HorizontalFacing): VoxelPlan { + assertDefinition(module); + assertFacing(facing); + const turns = (FACING_INDEX.get(facing)! - FACING_INDEX.get(module.canonicalFacing)! + FACINGS.length) % FACINGS.length; + const [width, height, depth] = module.bounds; + const size: Vec3 = turns % 2 === 0 ? [width, height, depth] : [depth, height, width]; + + return { + version: "0.1", + name: `${module.identity.id}@${module.identity.version} (${facing})`, + size, + origin: [0, 0, 0], + blocks: module.blocks.map((entry) => ({ + pos: rotatePosition(entry.pos, module.bounds, turns), + block: { name: entry.block, properties: rotateState(entry.properties, turns) }, + sourceNodeId: entry.role, + })), + }; +} diff --git a/packages/core/test/fixtures/redstone/item-sorter-slice.json b/packages/core/test/fixtures/redstone/item-sorter-slice.json new file mode 100644 index 0000000..042ad35 --- /dev/null +++ b/packages/core/test/fixtures/redstone/item-sorter-slice.json @@ -0,0 +1,1366 @@ +{ + "identity": { + "id": "minepilot:item_sorter_slice", + "version": "0.5" + }, + "canonicalFacing": "east", + "bounds": [ + 6, + 5, + 1 + ], + "blocks": [ + { + "role": "transport_hopper_0", + "pos": [ + 1, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "transport_hopper_1", + "pos": [ + 2, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "transport_hopper_2", + "pos": [ + 3, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "transport_hopper_3", + "pos": [ + 4, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "overflow_output", + "pos": [ + 5, + 4, + 0 + ], + "block": "minecraft:barrel", + "properties": {} + }, + { + "role": "filter_hopper", + "pos": [ + 4, + 3, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "west", + "enabled": "true" + } + }, + { + "role": "output_hopper", + "pos": [ + 4, + 2, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "filtered_output", + "pos": [ + 5, + 2, + 0 + ], + "block": "minecraft:chest", + "properties": { + "facing": "south", + "type": "single", + "waterlogged": "false" + } + }, + { + "role": "comparator_filter", + "pos": [ + 3, + 3, + 0 + ], + "block": "minecraft:comparator", + "properties": { + "facing": "west", + "mode": "compare", + "powered": "false" + } + }, + { + "role": "wire_threshold_0", + "pos": [ + 2, + 3, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "north": "none", + "east": "side", + "south": "none", + "west": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_1", + "pos": [ + 1, + 3, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "north": "none", + "east": "side", + "south": "none", + "west": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_2", + "pos": [ + 0, + 2, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "north": "none", + "east": "side", + "south": "none", + "west": "side", + "power": "0" + } + }, + { + "role": "support_wire_0", + "pos": [ + 1, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_1", + "pos": [ + 2, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_comparator", + "pos": [ + 3, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_2", + "pos": [ + 0, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater_output", + "pos": [ + 2, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater", + "pos": [ + 1, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_output_lock", + "pos": [ + 4, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "repeater_threshold", + "pos": [ + 1, + 1, + 0 + ], + "block": "minecraft:repeater", + "properties": { + "facing": "east", + "delay": "1", + "locked": "false", + "powered": "false" + } + }, + { + "role": "locking_torch", + "pos": [ + 3, + 1, + 0 + ], + "block": "minecraft:redstone_wall_torch", + "properties": { + "facing": "west", + "lit": "true" + } + } + ], + "expected": { + "north": { + "bounds": [ + 1, + 5, + 6 + ], + "blocks": [ + { + "role": "transport_hopper_0", + "pos": [ + 0, + 4, + 4 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "north", + "enabled": "true" + } + }, + { + "role": "transport_hopper_1", + "pos": [ + 0, + 4, + 3 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "north", + "enabled": "true" + } + }, + { + "role": "transport_hopper_2", + "pos": [ + 0, + 4, + 2 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "north", + "enabled": "true" + } + }, + { + "role": "transport_hopper_3", + "pos": [ + 0, + 4, + 1 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "north", + "enabled": "true" + } + }, + { + "role": "overflow_output", + "pos": [ + 0, + 4, + 0 + ], + "block": "minecraft:barrel", + "properties": {} + }, + { + "role": "filter_hopper", + "pos": [ + 0, + 3, + 1 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "south", + "enabled": "true" + } + }, + { + "role": "output_hopper", + "pos": [ + 0, + 2, + 1 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "north", + "enabled": "true" + } + }, + { + "role": "filtered_output", + "pos": [ + 0, + 2, + 0 + ], + "block": "minecraft:chest", + "properties": { + "facing": "east", + "type": "single", + "waterlogged": "false" + } + }, + { + "role": "comparator_filter", + "pos": [ + 0, + 3, + 2 + ], + "block": "minecraft:comparator", + "properties": { + "facing": "south", + "mode": "compare", + "powered": "false" + } + }, + { + "role": "wire_threshold_0", + "pos": [ + 0, + 3, + 3 + ], + "block": "minecraft:redstone_wire", + "properties": { + "west": "none", + "north": "side", + "east": "none", + "south": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_1", + "pos": [ + 0, + 3, + 4 + ], + "block": "minecraft:redstone_wire", + "properties": { + "west": "none", + "north": "side", + "east": "none", + "south": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_2", + "pos": [ + 0, + 2, + 5 + ], + "block": "minecraft:redstone_wire", + "properties": { + "west": "none", + "north": "side", + "east": "none", + "south": "side", + "power": "0" + } + }, + { + "role": "support_wire_0", + "pos": [ + 0, + 2, + 4 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_1", + "pos": [ + 0, + 2, + 3 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_comparator", + "pos": [ + 0, + 2, + 2 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_2", + "pos": [ + 0, + 1, + 5 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater_output", + "pos": [ + 0, + 1, + 3 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater", + "pos": [ + 0, + 0, + 4 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_output_lock", + "pos": [ + 0, + 1, + 1 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "repeater_threshold", + "pos": [ + 0, + 1, + 4 + ], + "block": "minecraft:repeater", + "properties": { + "facing": "north", + "delay": "1", + "locked": "false", + "powered": "false" + } + }, + { + "role": "locking_torch", + "pos": [ + 0, + 1, + 2 + ], + "block": "minecraft:redstone_wall_torch", + "properties": { + "facing": "south", + "lit": "true" + } + } + ] + }, + "east": { + "bounds": [ + 6, + 5, + 1 + ], + "blocks": [ + { + "role": "transport_hopper_0", + "pos": [ + 1, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "transport_hopper_1", + "pos": [ + 2, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "transport_hopper_2", + "pos": [ + 3, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "transport_hopper_3", + "pos": [ + 4, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "overflow_output", + "pos": [ + 5, + 4, + 0 + ], + "block": "minecraft:barrel", + "properties": {} + }, + { + "role": "filter_hopper", + "pos": [ + 4, + 3, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "west", + "enabled": "true" + } + }, + { + "role": "output_hopper", + "pos": [ + 4, + 2, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "filtered_output", + "pos": [ + 5, + 2, + 0 + ], + "block": "minecraft:chest", + "properties": { + "facing": "south", + "type": "single", + "waterlogged": "false" + } + }, + { + "role": "comparator_filter", + "pos": [ + 3, + 3, + 0 + ], + "block": "minecraft:comparator", + "properties": { + "facing": "west", + "mode": "compare", + "powered": "false" + } + }, + { + "role": "wire_threshold_0", + "pos": [ + 2, + 3, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "north": "none", + "east": "side", + "south": "none", + "west": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_1", + "pos": [ + 1, + 3, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "north": "none", + "east": "side", + "south": "none", + "west": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_2", + "pos": [ + 0, + 2, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "north": "none", + "east": "side", + "south": "none", + "west": "side", + "power": "0" + } + }, + { + "role": "support_wire_0", + "pos": [ + 1, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_1", + "pos": [ + 2, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_comparator", + "pos": [ + 3, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_2", + "pos": [ + 0, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater_output", + "pos": [ + 2, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater", + "pos": [ + 1, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_output_lock", + "pos": [ + 4, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "repeater_threshold", + "pos": [ + 1, + 1, + 0 + ], + "block": "minecraft:repeater", + "properties": { + "facing": "east", + "delay": "1", + "locked": "false", + "powered": "false" + } + }, + { + "role": "locking_torch", + "pos": [ + 3, + 1, + 0 + ], + "block": "minecraft:redstone_wall_torch", + "properties": { + "facing": "west", + "lit": "true" + } + } + ] + }, + "south": { + "bounds": [ + 1, + 5, + 6 + ], + "blocks": [ + { + "role": "transport_hopper_0", + "pos": [ + 0, + 4, + 1 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "south", + "enabled": "true" + } + }, + { + "role": "transport_hopper_1", + "pos": [ + 0, + 4, + 2 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "south", + "enabled": "true" + } + }, + { + "role": "transport_hopper_2", + "pos": [ + 0, + 4, + 3 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "south", + "enabled": "true" + } + }, + { + "role": "transport_hopper_3", + "pos": [ + 0, + 4, + 4 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "south", + "enabled": "true" + } + }, + { + "role": "overflow_output", + "pos": [ + 0, + 4, + 5 + ], + "block": "minecraft:barrel", + "properties": {} + }, + { + "role": "filter_hopper", + "pos": [ + 0, + 3, + 4 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "north", + "enabled": "true" + } + }, + { + "role": "output_hopper", + "pos": [ + 0, + 2, + 4 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "south", + "enabled": "true" + } + }, + { + "role": "filtered_output", + "pos": [ + 0, + 2, + 5 + ], + "block": "minecraft:chest", + "properties": { + "facing": "west", + "type": "single", + "waterlogged": "false" + } + }, + { + "role": "comparator_filter", + "pos": [ + 0, + 3, + 3 + ], + "block": "minecraft:comparator", + "properties": { + "facing": "north", + "mode": "compare", + "powered": "false" + } + }, + { + "role": "wire_threshold_0", + "pos": [ + 0, + 3, + 2 + ], + "block": "minecraft:redstone_wire", + "properties": { + "east": "none", + "south": "side", + "west": "none", + "north": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_1", + "pos": [ + 0, + 3, + 1 + ], + "block": "minecraft:redstone_wire", + "properties": { + "east": "none", + "south": "side", + "west": "none", + "north": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_2", + "pos": [ + 0, + 2, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "east": "none", + "south": "side", + "west": "none", + "north": "side", + "power": "0" + } + }, + { + "role": "support_wire_0", + "pos": [ + 0, + 2, + 1 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_1", + "pos": [ + 0, + 2, + 2 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_comparator", + "pos": [ + 0, + 2, + 3 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_2", + "pos": [ + 0, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater_output", + "pos": [ + 0, + 1, + 2 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater", + "pos": [ + 0, + 0, + 1 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_output_lock", + "pos": [ + 0, + 1, + 4 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "repeater_threshold", + "pos": [ + 0, + 1, + 1 + ], + "block": "minecraft:repeater", + "properties": { + "facing": "south", + "delay": "1", + "locked": "false", + "powered": "false" + } + }, + { + "role": "locking_torch", + "pos": [ + 0, + 1, + 3 + ], + "block": "minecraft:redstone_wall_torch", + "properties": { + "facing": "north", + "lit": "true" + } + } + ] + }, + "west": { + "bounds": [ + 6, + 5, + 1 + ], + "blocks": [ + { + "role": "transport_hopper_0", + "pos": [ + 4, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "west", + "enabled": "true" + } + }, + { + "role": "transport_hopper_1", + "pos": [ + 3, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "west", + "enabled": "true" + } + }, + { + "role": "transport_hopper_2", + "pos": [ + 2, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "west", + "enabled": "true" + } + }, + { + "role": "transport_hopper_3", + "pos": [ + 1, + 4, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "west", + "enabled": "true" + } + }, + { + "role": "overflow_output", + "pos": [ + 0, + 4, + 0 + ], + "block": "minecraft:barrel", + "properties": {} + }, + { + "role": "filter_hopper", + "pos": [ + 1, + 3, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "east", + "enabled": "true" + } + }, + { + "role": "output_hopper", + "pos": [ + 1, + 2, + 0 + ], + "block": "minecraft:hopper", + "properties": { + "facing": "west", + "enabled": "true" + } + }, + { + "role": "filtered_output", + "pos": [ + 0, + 2, + 0 + ], + "block": "minecraft:chest", + "properties": { + "facing": "north", + "type": "single", + "waterlogged": "false" + } + }, + { + "role": "comparator_filter", + "pos": [ + 2, + 3, + 0 + ], + "block": "minecraft:comparator", + "properties": { + "facing": "east", + "mode": "compare", + "powered": "false" + } + }, + { + "role": "wire_threshold_0", + "pos": [ + 3, + 3, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "south": "none", + "west": "side", + "north": "none", + "east": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_1", + "pos": [ + 4, + 3, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "south": "none", + "west": "side", + "north": "none", + "east": "side", + "power": "0" + } + }, + { + "role": "wire_threshold_2", + "pos": [ + 5, + 2, + 0 + ], + "block": "minecraft:redstone_wire", + "properties": { + "south": "none", + "west": "side", + "north": "none", + "east": "side", + "power": "0" + } + }, + { + "role": "support_wire_0", + "pos": [ + 4, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_1", + "pos": [ + 3, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_comparator", + "pos": [ + 2, + 2, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_wire_2", + "pos": [ + 5, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater_output", + "pos": [ + 3, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_repeater", + "pos": [ + 4, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "support_output_lock", + "pos": [ + 1, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "repeater_threshold", + "pos": [ + 4, + 1, + 0 + ], + "block": "minecraft:repeater", + "properties": { + "facing": "west", + "delay": "1", + "locked": "false", + "powered": "false" + } + }, + { + "role": "locking_torch", + "pos": [ + 2, + 1, + 0 + ], + "block": "minecraft:redstone_wall_torch", + "properties": { + "facing": "east", + "lit": "true" + } + } + ] + } + }, + "provenance": { + "verifiedProof": "https://github.com/i365dev/MinePilot/pull/134", + "pullRequest": 134 + } +} diff --git a/packages/core/test/fixtures/redstone/quasi-connected-piston-door.json b/packages/core/test/fixtures/redstone/quasi-connected-piston-door.json new file mode 100644 index 0000000..787b29a --- /dev/null +++ b/packages/core/test/fixtures/redstone/quasi-connected-piston-door.json @@ -0,0 +1,407 @@ +{ + "identity": { + "id": "minepilot:quasi_connected_piston_door", + "version": "1.0" + }, + "canonicalFacing": "east", + "bounds": [ + 3, + 2, + 1 + ], + "blocks": [ + { + "role": "support", + "pos": [ + 0, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "lever", + "pos": [ + 0, + 1, + 0 + ], + "block": "minecraft:lever", + "properties": { + "face": "floor", + "facing": "north", + "powered": "false" + } + }, + { + "role": "lower_sticky_piston", + "pos": [ + 1, + 0, + 0 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "east", + "extended": "false" + } + }, + { + "role": "upper_sticky_piston", + "pos": [ + 1, + 1, + 0 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "east", + "extended": "false" + } + }, + { + "role": "lower_movable", + "pos": [ + 2, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "upper_movable", + "pos": [ + 2, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + } + ], + "expected": { + "north": { + "bounds": [ + 1, + 2, + 3 + ], + "blocks": [ + { + "role": "support", + "pos": [ + 0, + 0, + 2 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "lever", + "pos": [ + 0, + 1, + 2 + ], + "block": "minecraft:lever", + "properties": { + "face": "floor", + "facing": "west", + "powered": "false" + } + }, + { + "role": "lower_sticky_piston", + "pos": [ + 0, + 0, + 1 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "north", + "extended": "false" + } + }, + { + "role": "upper_sticky_piston", + "pos": [ + 0, + 1, + 1 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "north", + "extended": "false" + } + }, + { + "role": "lower_movable", + "pos": [ + 0, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "upper_movable", + "pos": [ + 0, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + } + ] + }, + "east": { + "bounds": [ + 3, + 2, + 1 + ], + "blocks": [ + { + "role": "support", + "pos": [ + 0, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "lever", + "pos": [ + 0, + 1, + 0 + ], + "block": "minecraft:lever", + "properties": { + "face": "floor", + "facing": "north", + "powered": "false" + } + }, + { + "role": "lower_sticky_piston", + "pos": [ + 1, + 0, + 0 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "east", + "extended": "false" + } + }, + { + "role": "upper_sticky_piston", + "pos": [ + 1, + 1, + 0 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "east", + "extended": "false" + } + }, + { + "role": "lower_movable", + "pos": [ + 2, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "upper_movable", + "pos": [ + 2, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + } + ] + }, + "south": { + "bounds": [ + 1, + 2, + 3 + ], + "blocks": [ + { + "role": "support", + "pos": [ + 0, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "lever", + "pos": [ + 0, + 1, + 0 + ], + "block": "minecraft:lever", + "properties": { + "face": "floor", + "facing": "east", + "powered": "false" + } + }, + { + "role": "lower_sticky_piston", + "pos": [ + 0, + 0, + 1 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "south", + "extended": "false" + } + }, + { + "role": "upper_sticky_piston", + "pos": [ + 0, + 1, + 1 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "south", + "extended": "false" + } + }, + { + "role": "lower_movable", + "pos": [ + 0, + 0, + 2 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "upper_movable", + "pos": [ + 0, + 1, + 2 + ], + "block": "minecraft:stone", + "properties": {} + } + ] + }, + "west": { + "bounds": [ + 3, + 2, + 1 + ], + "blocks": [ + { + "role": "support", + "pos": [ + 2, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "lever", + "pos": [ + 2, + 1, + 0 + ], + "block": "minecraft:lever", + "properties": { + "face": "floor", + "facing": "south", + "powered": "false" + } + }, + { + "role": "lower_sticky_piston", + "pos": [ + 1, + 0, + 0 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "west", + "extended": "false" + } + }, + { + "role": "upper_sticky_piston", + "pos": [ + 1, + 1, + 0 + ], + "block": "minecraft:sticky_piston", + "properties": { + "facing": "west", + "extended": "false" + } + }, + { + "role": "lower_movable", + "pos": [ + 0, + 0, + 0 + ], + "block": "minecraft:stone", + "properties": {} + }, + { + "role": "upper_movable", + "pos": [ + 0, + 1, + 0 + ], + "block": "minecraft:stone", + "properties": {} + } + ] + } + }, + "provenance": { + "verifiedProof": "https://github.com/i365dev/MinePilot/pull/136", + "pullRequest": 136, + "mergedCommit": "171926a535389a3446dcc9a1f4ca52f0a38e8afc" + } +} diff --git a/packages/core/test/redstone-module-kernel.test.ts b/packages/core/test/redstone-module-kernel.test.ts new file mode 100644 index 0000000..3b28324 --- /dev/null +++ b/packages/core/test/redstone-module-kernel.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { compileStaticModule, type StaticModuleDefinition } from "../src/redstone/moduleKernel.js"; + +interface ExpectedTopology { + bounds: [number, number, number]; + blocks: Array<{ role: string; pos: [number, number, number]; block: string; properties: Record }>; +} + +interface RegressionFixture extends StaticModuleDefinition { + expected: Record; + provenance: { verifiedProof: string; pullRequest: number; mergedCommit?: string }; +} + +function fixture(name: string): RegressionFixture { + return JSON.parse(readFileSync(new URL(`./fixtures/redstone/${name}.json`, import.meta.url), "utf8")) as RegressionFixture; +} + +function topology(plan: ReturnType): ExpectedTopology { + return { + bounds: plan.size, + blocks: plan.blocks.map(({ sourceNodeId, pos, block }) => ({ + role: sourceNodeId!, + pos, + block: block.name, + properties: block.properties ?? {}, + })), + }; +} + +const cases = [ + ["ItemSorterSlice", "item-sorter-slice"], + ["quasi-connected PistonDoor", "quasi-connected-piston-door"], +] as const; + +describe.each(cases)("static module kernel: %s", (_label, file) => { + const sample = fixture(file); + const definition: StaticModuleDefinition = { + identity: sample.identity, + canonicalFacing: sample.canonicalFacing, + bounds: sample.bounds, + blocks: sample.blocks, + }; + + it("matches canonical authored topology and all four static verified outputs", () => { + expect(sample.provenance.verifiedProof).toMatch(/^https:\/\/github\.com\/i365dev\/MinePilot\/pull\/(134|136)$/); + expect(topology(compileStaticModule(definition, definition.canonicalFacing))).toEqual(sample.expected[definition.canonicalFacing]); + for (const facing of ["north", "east", "south", "west"] as const) { + expect(topology(compileStaticModule(definition, facing))).toEqual(sample.expected[facing]); + } + }); + + it("produces ordinary VoxelPlans deterministically on repeated compilation", () => { + for (const facing of ["north", "east", "south", "west"] as const) { + const first = compileStaticModule(definition, facing); + const second = compileStaticModule(definition, facing); + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + expect(first.blocks.every((block) => Boolean(block.sourceNodeId))).toBe(true); + } + }); + + it("fails closed for invalid facings, malformed topology, and unsupported directional states", () => { + expect(() => compileStaticModule(definition, "up" as never)).toThrow(/unsupported horizontal facing/); + + const duplicateCoordinate = structuredClone(definition); + duplicateCoordinate.blocks[1].pos = [...duplicateCoordinate.blocks[0].pos]; + expect(() => compileStaticModule(duplicateCoordinate, definition.canonicalFacing)).toThrow(/occupied-coordinate collision/); + + const outOfBounds = structuredClone(definition); + outOfBounds.blocks[0].pos = [outOfBounds.bounds[0], 0, 0]; + expect(() => compileStaticModule(outOfBounds, definition.canonicalFacing)).toThrow(/outside module bounds/); + + const duplicateRole = structuredClone(definition); + duplicateRole.blocks[1].role = duplicateRole.blocks[0].role; + expect(() => compileStaticModule(duplicateRole, definition.canonicalFacing)).toThrow(/duplicate block role/); + + const unsupportedFacing = structuredClone(definition); + const facingBlock = unsupportedFacing.blocks.find((entry) => entry.properties.facing); + expect(facingBlock).toBeDefined(); + facingBlock!.properties.facing = "diagonal"; + expect(() => compileStaticModule(unsupportedFacing, definition.canonicalFacing)).toThrow(/unsupported horizontal facing/); + + const unsupportedState = structuredClone(definition); + unsupportedState.blocks[0].properties.axis = "x"; + const otherFacing = definition.canonicalFacing === "north" ? "east" : "north"; + expect(() => compileStaticModule(unsupportedState, otherFacing)).toThrow(/unsupported block-state property during rotation: axis/); + + const unknownState = structuredClone(definition); + unknownState.blocks[0].properties.mystery_direction = "north-east-ish"; + expect(() => compileStaticModule(unknownState, otherFacing)).toThrow(/unsupported block-state property during rotation: mystery_direction/); + const unchanged = compileStaticModule(unknownState, definition.canonicalFacing); + expect(unchanged.blocks[0].block.properties?.mystery_direction).toBe("north-east-ish"); + }); +});