diff --git a/docs/block-entities.md b/docs/block-entities.md index 0e6357f1..5ab08b03 100644 --- a/docs/block-entities.md +++ b/docs/block-entities.md @@ -131,11 +131,117 @@ The block delegates `newBlockEntity` and `getTicker` to `binding.prepared()`, as it, `DropsContents` is never called and nothing reports the missing hook. It reads `binding.type()` only after registration (for example when a block entity is created), never in its constructor. +## BasePoweredMachine + +`zone.moddev.mc.lib.machine.neoforge.BasePoweredMachine` is an optional abstract `BlockEntity` +for code-defined machines. It implements `ScreenHost`, `DropsContents` and `Clearable`. It owns +`energy` and `features`, exposes their screen/statistics bindings, and supplies ticking, persistence, +feature synchronization and common warnings. It does not register blocks, capabilities, recipes +or a recovery item for the consuming mod. + +The default constructor configures 100,000 FE capacity, 1,000 FE input and no external output. +The overload `(type, pos, state, capacity, maxInput, maxOutput)` accepts different values, including +an output-only generator. The owning machine's energy operations bypass external transfer rates. + +Define items/tanks and resource groups in the subclass, then connect them once: + +```java +// In a BasePoweredMachine subclass constructor, after defining the three ResourceGroups: +resources = new MachineResources(this, this::facing) + .items(inputGroup, items) + .items(outputGroup, items) + .energy(energyGroup, energy) + .recipeInputs(MyRecipes.PRESSING, "input") + .build(); +features.add(new RecipeProcessor("work", MyRecipes.PRESSING, resources.recipes(), + Component.translatable("example.press.processor"))); +inventory = poweredInventory(resources.automation()) + .items("items", items) + .fluids("fluids", fluids); +ScreenInventoryValidation.validate(this); + +// Subclass methods; inventory is the configured MachineInventoryLifecycle field. +@Override public ItemStorage screenItems() { return items; } +@Override protected MachineInventoryLifecycle machineInventory() { return inventory; } +@Override protected void recoverResources(Level level, BlockPos pos, CompoundTag payload) { + // Consumer-owned item that durably retains the complete payload: + Containers.dropItemStack(level, pos.getX() + .5, pos.getY() + .5, pos.getZ() + .5, + MyRecoveryItem.create(payload)); +} +``` + +Group IDs in this fragment are `input`, `output` and `energy`; recipe files must use the matching +keys. See [resource definitions and filtering](inventory-automation.md#shared-machine-resource-definitions). +`MyRecipes` and `MyRecoveryItem` are consumer implementations, not library registrations. + +The base automatically registers its own buffer under the save key `energy`, even when the +subclass returns a directly constructed lifecycle instead of using `poweredInventory`. A lifecycle +must belong to this entity and use its `features` and `energy`; conflicting energy bindings are +rejected. Without a lifecycle, the base saves/restores features and energy only. Custom items and +tanks must be registered with the lifecycle to participate in persistence, clearing and dropping. + +| Base behavior | Subclass extension | +|---|---| +| `getEnergy()`, `getEnergyCapacity()` | Backed by the owned buffer | +| `getProgress()`, `getTime()`, `getPaused()` | First registered recipe processor; zero/false if none | +| `screenFeatures()`, `screenStats()` | Owned host; statistics include energy and all feature statistics providers | +| `screenAutomation()` | Lifecycle's automation inventory, when configured | +| `getWarnings()` | Redstone processing gate plus `STALLED`, `BLOCKED`, `HELD` processor warnings, using each processor's display name; append custom messages to `super.getWarnings()` | +| `screenRedstone()` | Opt in with a configured `RedstoneControl`; base reads it before processing and updates outputs afterward | +| `processingAllowed()` | Additional processing gate, default true | +| `pausePolicy()` | Default `PROCESSING_ONLY`; controls redstone gating of processing and automation transfers | +| `beforeMachineTick()`, `afterMachineTick()` | Work around the common processing/transfer tick, such as generator refilling and output | +| `saveMachineData()`, `loadMachineData()` | Additional persistent metadata, after the common lifecycle; never used for client-only packets | +| `recoverResources()` | Required durable handoff for displaced storage and transfer escrow | + +The final no-argument `serverTick()` is server-only. Register its block ticker with +`(level, pos, state, entity) -> entity.serverTick()`. Custom before/after tick hooks run even when +the processing gate is held, so gate any custom work explicitly if necessary. Manual processor +pause remains independent of the redstone transfer policy. + +`MachineInventoryLifecycle` resets features before loading storage, then restores automation and +features, so reservations are rebuilt against loaded contents. It settles owed energy refunds even +when transfers are paused. `clearContent()` destructively clears registered contents, recovery, +automation and feature state. `dropContents()` recovers displaced contents and transfer escrow, +releases plans and drops ordinary items. Normal tank contents and stored power are not converted +to recovery payloads on destruction. Custom blocks still need the `DropsContents.onRemove` hook. + +Screen XML extends `mmdlib:base_powered_machine`; `SCREEN_TEMPLATE` exposes that ID in Java. +The final `openScreen(player)` opens the concrete type's screen, not the abstract template. +See [screen inheritance](gui-screens.md#inherited-screens) for the shared energy display, +processor control, info text and placeholders. The mod still supplies a screen under each type ID. + +### Additional client state + +The base's final update packet/tag methods always include feature synchronization. Extend them +through these protected hooks instead of overriding the packet methods: + +```java +@Override protected void saveClientData(CompoundTag tag, HolderLookup.Provider registries) { + tag.putInt("visual_mode", visualMode); +} +@Override protected void loadClientData(CompoundTag tag, HolderLookup.Provider registries) { + visualMode = tag.getInt("visual_mode"); +} +// After a server-side change that should reach tracking clients: +syncToClient(); +``` + +Custom data occupies a separate `mmdlib_machine` compound; it cannot replace the root feature or +storage fields. Features restore first, followed by `loadClientData`. Older feature-only packets +leave custom client state alone. World saves never invoke these hooks, and packet loads never +invoke `loadMachineData` or reload inventories. Persist any value that must survive a world reload +separately in the machine-data hooks and call `setChanged()` when changing it; `syncToClient()` +only sends current state. Exact screen values still use the menu's getter synchronization. + ## Machine features The separately packaged [machine companion](machine-companion.md) provides playable examples of the processor, a one-slot converter, shared lanes and a creative power source. +The manual wiring below is for a plain `BlockEntity`. When extending `BasePoweredMachine`, use its +owned `features` and lifecycle instead of redeclaring the host or overriding final save/tick methods. + A `FeatureHost` runs a machine's `MachineFeature`s, such as a `RecipeProcessor`, and saves each under its key. The block entity ticks it from `serverTick` with `features.tick(level)`, and saves and loads it through a `DynamicOps`, after the storages, since a processor checks the loaded @@ -164,8 +270,9 @@ BlockEntityTicker ticker = `status()` is one of: `IDLE`, no cycle is running; `RUNNING`, a cycle is running; `STALLED`, a cycle is waiting for energy and keeps its progress and reservations; `BLOCKED`, a cycle has run its -time but cannot finish, say an input was taken, and retries each tick; and `PAUSED`, held by an -interrupt. `progress()` and `time()` give the ticks done and the ticks the cycle takes, for a +time but cannot finish, say an input was taken, and retries each tick; `HELD`, suspended by a host +gate with progress and reservations retained; and `PAUSED`, held by a manual interrupt. +`progress()` and `time()` give the ticks done and the ticks the cycle takes, for a screen; `time()` is 0 when idle or paused. On `/reload` a running cycle finishes as planned, and the next cycle uses the new recipes. @@ -181,13 +288,17 @@ until `resume()`; `paused()` says whether it is held. It works without power and status. It also releases the running cycle's reservations, so the items it locked can be taken out. This is the trap for players: an unpowered machine keeps its inputs and outputs locked, and the only ways to get them back are an interrupt or breaking the block. A mod should give the -player a button, an `Element.Button` in the screen XML with a `gui-click` handler (see -[GUI screens](gui-screens.md)) that calls `interrupt()` or `resume()`. Resuming reserves the paused +player a generic `` (see [GUI screens](gui-screens.md#generic-processor-control)). +`pause()`, `resume()` and `setPaused(boolean)` expose the same processor operations. For actions +on a hosted processor, use `FeatureHost.setProcessorPaused(key, paused)`: it marks changes dirty +and synchronizes immediately, even while host processing is held. Direct processor calls require +the caller to arrange persistence/sync. Resuming reserves the paused recipe again if it still can. Progress is kept only if that recipe still exists, can reserve its inputs and output space again, and has the same `time`; otherwise progress resets. -**Groups.** The processor reaches storage through the inventory's group keys. Groups in one -inventory must not overlap: use one key for each set of slots or tanks. Multiple processor lanes +**Groups.** The processor reaches storage through the inventory's group keys. Physical groups in +`MachineResources` cannot overlap; recipe aliases may expose the same storage through alternate +keys. Avoid describing the same physical input/output twice within one recipe. Multiple processor lanes can share that inventory and reserve the same slot, provided enough unreserved inputs and output space remain; each lane holds only its own share of the slot's plan. @@ -217,7 +328,8 @@ given back. Custom blocks can call `DropsContents.onRemove(state, level, pos, replacement)` before their superclass removal hook to use the same server-only, different-block guard. -**Syncing to clients.** Nothing is sent to clients unless the block entity opts in. A feature that +**Syncing to clients.** `BasePoweredMachine` wires this automatically. A plain block entity must +opt in. A feature that should be readable on the client, for a renderer, particles or a tooltip, returns true from `syncsToClient()`; `RecipeProcessor` does. The block entity then wires `FeatureSync`: @@ -243,8 +355,10 @@ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) } ``` -The host sends at most once every 10 ticks (`FeatureHost.SYNC_INTERVAL`), and always sends the -latest change. Storages are not part of the update. +Tick-driven feature changes are throttled to one send every 10 ticks (`FeatureHost.SYNC_INTERVAL`), +with the latest change retained. Hosted manual pause changes synchronize immediately. Storages +are not part of the feature update. `features.tick(level, false)` still calls each feature's +`suspended(host)` hook, allowing processors to publish `HELD` rather than leaving stale running state. A feature controls what clients get with two methods. `clientState()` is the state to send, by default what `state()` saves; a feature overrides it for a moment clients shouldn't see. diff --git a/docs/capabilities.md b/docs/capabilities.md index 1fe28fa9..4e972fb6 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -68,6 +68,38 @@ What a plan does to the calls on the storage: Plans are not saved by the storages. A feature saves its own cycle and puts the plans back when it loads, so load the storages first. +## Stable storage identities and recovery + +Assign durable identities with `items.slotId(index, "input")` and `fluids.tankId(index, "coolant")`. +IDs must be nonblank and unique within that storage. They are save identities, separate from +numeric recipe/menu indexes and from resource-group or XML element IDs. + +Saves include `SlotId` / `TankId` where configured. Loading matches identified entries by ID; +an unknown ID never falls back to its former numeric position. Saves without identities load by +index, or through the explicit `deserializeNBT(registries, tag, IntUnaryOperator)` migration +overload. Select that mapping by your machine's save version; it applies only to legacy entries. +Keep slot/tank IDs stable when reordering a definition. + +Storage keeps its configured shape. Removed slots/tanks, overflow after reducing limits/capacity, +incompatible colliding contents and undecodable entries are retained in raw `Recovery` data, +instead of being discarded. Subsequent loads retry recovery entries, allowing returning IDs or +registrations to make them usable again. Loading restores existing contents without treating them +as a new insertion through recipe filters. + +`recoveryContents()` returns a snapshot. `recover(Consumer)` hands displaced contents +to a durable consumer-owned sink, clearing them only after the callback succeeds. `StorageRecovery` +uses the same `resources` envelope as automation escrow, with `mmdlib:items` / `mmdlib:fluids` +payloads. MMDLib does not register a recovery item; the companion demonstrates a Recovery Capsule. +Use `MachineInventoryLifecycle` or [BasePoweredMachine](block-entities.md#basepoweredmachine) to +coordinate storage, reservations, saving, clearing and this handoff. Explicit `clear()` destroys +both ordinary and recovery contents; stop/reset owning features first. + +External admission rules can be combined with `restrict(predicate, indexes...)`. Recipe-linked +input rules are installed by [MachineResources](inventory-automation.md#shared-machine-resource-definitions). +They supplement existing filters and reservations. Machine products use `insertResult` for items +or `fillResult` for fluids to bypass external admission rules; fluid products still obey the +tank's structural filter and capacity. Restoring a saved item explicitly can use `restoreStack`. + ## Example ```java @@ -126,10 +158,11 @@ is checked every tick, keep a NeoForge `BlockCapabilityCache` instead. the block entity returns them from `ScreenHost.screenSides()`. A view still takes one mode per face for the whole storage, so the mod's `sides` function decides how groups combine. When groups differ by `Role`, take insert from the input group and extract from the output group. - A per-slot mode in the views, which would remove that step, is not implemented. + Per-slot/per-tank mode functions are also supported. Prefer `MachineResources` and + `automation.mode(type, index, side)` for independent groups sharing a storage. -- `ItemStorage` keeps the slot count given to its constructor. Loading a save that has more - slots drops the stacks that no longer fit. `FluidStorage` loads its tanks by index. +- `ItemStorage` keeps its configured slot count and `FluidStorage` its configured tanks. Entries + that cannot be restored remain in recovery; see stable identities above. - `EnergyBuffer` stores up to `Long.MAX_VALUE`. Screens show the full `long` through a getter such as `#getEnergy` (`stored()`), while other mods see at most `Integer.MAX_VALUE`. - The runtime smoke test covers roles, filters, limits, faces, out-of-range indices, transfer, diff --git a/docs/gui-screens.md b/docs/gui-screens.md index 0d705e62..0446db6d 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -1,4 +1,4 @@ -# GUI screens (design) +# GUI screens > **Status: implemented; runtime validation is described below.** Screens load at startup, > open through `block-right-click`, and use one generic menu with custom value packets. @@ -17,6 +17,7 @@ embedded editor without adding per-filter XML to each screen. | Reading and checks | `gui.load.ScreenReader`: hardened SAX parser (no DOCTYPE, no external entities), schema validation, then the checks below | Done | | Getter binding | `gui.load.ScreenBinder`: `#method` / `Class#method` resolved to method handles, return kinds checked per element | Done | | Discovery | `gui.load.ScreenLoader`, called at startup; results in `gui.Screens` | Done | +| Inheritance | `gui.load.ScreenTemplates`: cross-mod bases, placeholders/fills, source-aware expansion | Done | | Layout | `gui.layout.ScreenLayout`: positions in GUI pixels; tab fit checked at load | Done | | Menu and opening | `gui.runtime.MachineMenu`, `ScreenRuntime`, `BlockEvents.openMenu` | Implemented | | Value sync | `gui.runtime.ScreenNetwork`, `ScreenValues`, `ScreenSnapshots` | Implemented | @@ -24,7 +25,8 @@ embedded editor without adding per-filter XML to each screen. | Optional JEI | `client.jei.MachineJei`, loaded only by the JEI plugin | Implemented | Checks made at load, beyond the schema: unique `id`s and slot indexes; `shows`, `hover-shows` -and `side-config groups` name existing elements; buttons have a tooltip and text or an icon; a +and `side-config groups` name existing elements; buttons have a tooltip and text or an icon +(generic processor controls supply their own defaults); a `-fallback` attribute has its key; `max` has a `value`; gauges, meters and progress indicators have both; checkboxes and radio groups have a `value`; info pop-outs have text and warning/error pop-outs a `value`; `spacer` sits directly in a box; at most one @@ -444,6 +446,118 @@ to the module's ID. Adding one later would break nothing, while removing one wou existing packs, so it will be added only if early users ask for it. If it is added, it must not hide a missing or misnamed screen file without a log entry. +## Inherited screens + +Use `abstract="true"` for a template and `extends="namespace:screen_id"` for a child. Templates +are discovered across mod namespaces but are not bound or opened themselves. Each concrete screen +still has its own file ID and must provide its own `title`. It can inherit `block-entity` and +`inventory`, or override either; getters bind against the resulting block-entity class. +Inheritance does not replace the type-ID screen lookup described above. + +```xml + + + + + + + + + +``` + +```xml + + + + +``` + +- Children supply content only through top-level `` elements. A fill replaces + the named placeholder's contents; an empty fill removes its default content. +- An unfilled optional placeholder uses its children, or nothing when it has none. A required + placeholder must be filled and cannot also have default content. +- Chains can introduce placeholders and override earlier fills. Replacing an outer placeholder + removes its nested extension points; also filling a removed inner point is an error. +- Unknown bases/fills, duplicate placeholders/fills and cycles fail at load. Expanded slots, IDs, + widget structure, bindings and layout are validated together. Errors retain the originating + file and line; a sibling's fills never mutate the shared template. + +`{machineId}` in translation keys expands to the concrete screen ID, including namespace and any +path, after inheritance: `{machineId}.info` becomes `example:press.info`, never the base ID. +It works in `text`, `title`, `label`, `tooltip`, `tooltip-shift` and button `value-labels`. +Literal `*-fallback` strings and runtime placeholders such as `{value}` / `{max}` are unchanged. +Declare the resulting key in the consuming mod's language file. + +### Library powered-machine screen + +MMDLib ships `mmdlib:base_powered_machine`, paired with +[`BasePoweredMachine`](block-entities.md#basepoweredmachine). A one-processor child can be: + +```xml + + + + + + + +``` + +The base supplies the entity binding, padded layout, right-side energy gauge, default player +inventory, keyless processor control and left info popout. Its info text is `{machineId}.info`, +with dynamic `#getWarnings` from the entity. Override `block-entity` when child widgets use getters +available only on the concrete subclass. Inventory/redstone/statistics drawers still come from +runtime host interfaces; XML inheritance does not configure their backing data. + +| Placeholder | Default | +|---|---| +| `body` | Entire padded machine area; replace for layouts with contexts/settings views | +| `heading` | Empty, above the machine row | +| `machine` | Required machine-specific contents, before the energy gauge | +| `inventory_controls` | `` | +| `info` | `` | + +Replacing `body` also replaces its nested `heading` and `machine` placeholders. Machines without +a recipe processor use ``; generators can also set +`inventory="none"`. Multi-processor machines can fill that control area with explicitly keyed buttons. + +### Generic processor control + +```xml + + + +``` + +The host exposes a `FeatureHost` through `screenFeatures()`; `BasePoweredMachine` supplies it. +An omitted key selects the first registered **recipe processor**, skipping other features. An +explicit key must match exactly and never falls back; blank keys and hosts with no matching +processor are errors. The menu validates this before opening and resolves clicks on the server. +The control owns its state binding and pause action, so it needs no per-machine getter or click +handler. Do not supply its `value`, `max`, `on-click`, `on-right-click`, `shows`, `icons` or `value-labels`. + +Icons show the current state, not the next action: + +| Processor state | Icon | Tooltip lines / click | +|---|---|---| +| Manually paused | Pause | `Paused` / `Click to unpause` | +| Not paused and processing | Stop | `Working` / `Click to stop and clear reservations` | +| Not paused and not processing | Play | `Idle` / `Click to pause` | + +Tooltips are compact and multiline. Manual pause releases reservations, keeps resumable progress, +marks the machine changed and synchronizes immediately. Redstone/host holds preserve reservations +and cannot override a manual pause. Removing a redstone hold never resumes a manually paused lane. + +When input watching is configured, a small red dot at the button's bottom right marks disagreement +between manual pause and the redstone gate. The extra tooltip explains either that manual pause +takes priority or that redstone is preventing processing. The redstone drawer icon has a small +green play overlay when watched inputs allow processing, or a yellow pause overlay when they +block it. With no input watching, neither the overlay nor conflict dot is shown. + ## Example A processing machine with an energy gauge, an input, a progress arrow, two outputs, a fluid tank, @@ -652,7 +766,8 @@ player, menu, and element before running the handler. Hover never reaches the se | Element | Purpose | Notable attributes | |---|---|---| -| `screen` | Root | `title`, `block-entity`, `inventory` | +| `screen` | Root | `title`, `block-entity`, `inventory`, `abstract`, `extends` | +| `placeholder`, `fill` | Template extension point / child replacement | `name`; placeholders can be `required` | | `vbox`, `hbox` | Layout boxes | `padding`, `spacing`, `align` | | `spacer` | Fills leftover room in a box | | | `group` | Recessed grouping panel | `label`, `tint`, `padding` | @@ -663,6 +778,8 @@ player, menu, and element before running the handler. Hover never reaches the se | `progress` | Fill-in progress indicator | `icon` (default arrow), `direction`, `jei-recipes` | | `label` | Text | `text` | | `button` | Vanilla-style button | `text` or `icon`, `tooltip` (required), `shows` (`context:view`) | +| `processor-button` | Generic server-controlled pause/stop/resume button | Optional `processor-key`; built-in state icons, tooltip and action | +| `inventory-controls` | Controls above player slots, right-aligned alongside the colour selector | Top-level container after the machine body | | `checkbox` | Checkbox | `text` | | `radio-group`, `radio` | Radio buttons; the group's value is the selected index | `label`, `text` | | `side-tab` | Tab growing out of the panel edge | `edge` | @@ -925,7 +1042,7 @@ bright green incoming strength for Read, orange emitted strength for Emit, with simplify only the info text to For N / Except N; ranges touching 0 or 15 use a single above/below condition (including equality for Between). The icon stays unchanged. Processing pauses when the face receives a highlighted level. - The default is Between 1–15. Dragging previews locally, release applies both endpoints atomically, + The default is Between 1–15. Dragging previews locally, release applies both endpoints atomically, and Escape cancels the drag. Any Read face requesting pause holds processing. - **Emit:** click the colour swatch or sprite and source name to cycle outputs; right-click cycles backwards. Resource fullness entries come first in the companion, followed by each diff --git a/docs/gui-sprites.md b/docs/gui-sprites.md index 3e172a71..d40768cc 100644 --- a/docs/gui-sprites.md +++ b/docs/gui-sprites.md @@ -29,6 +29,10 @@ icons and progress arrows are full colour. | `icon_side_config` | 16×16 | none | Central machine with connected cyan/orange ports for the side-configuration drawer | | `side_mode_input`, `side_mode_output`, `side_mode_both`, `side_mode_disabled` | 14×14 | none | Outlined cyan insertion arrow, orange extraction arrow, opposing arrows, or grey cross. The drawer draws 7×7 corner badges: input at top-right, output at bottom-right, both badges for bidirectional sides, or a bottom-right cross when disabled. | | `icon_pause`, `icon_resume` | 16×16 | none | Gold pause bars and green play triangle; `_a` and `_b` variants identify independent lanes | +| `icon_processor_pause`, `icon_processor_stop`, `icon_processor_play` | 12×12 | none | Centered current-state glyphs for the generic processor control: manually paused, working, idle | +| `icon_processor_conflict` | 5×5 | none | Red bottom-right conflict overlay for manual pause versus watched redstone inputs | +| `icon_redstone_running`, `icon_redstone_paused` | 5×5 | none | Green play / yellow pause overlays for the redstone drawer's input permission | +| `icon_stop` | 16×16 | none | Standalone stop glyph | | `button_flat`, `button_flat_highlighted` | 14×14 | nine-slice, border 1 | Compact button with a uniform dark outline and flat grey fill; hover lightens only the interior | | `icon_filter_whitelist`, `icon_filter_blacklist` | 10×10 | none | White/black list cards on the filter's flat grey mode toggle | | `icon_processor_progress` | 16x16 | none | Three rising blue bars for per-processor recipe progress. | diff --git a/docs/inventory-automation.md b/docs/inventory-automation.md index c3a6289d..555483ad 100644 --- a/docs/inventory-automation.md +++ b/docs/inventory-automation.md @@ -27,6 +27,59 @@ height. All drawers share the same frame renderer and four-pixel top inset. The drawer can grow to the available screen height; inventory drawers retain the panel's bottom inset. Info drawers fit their text up to the inset bottom or the screen edge, whichever comes first. +## Shared machine resource definitions + +`machine.neoforge.MachineResources` defines group membership once for automation, recipes and +screen highlights. Construct it after defining the physical `ItemStorage`, `FluidStorage` and +`EnergyBuffer`; resource-group IDs are separate from stable slot/tank save IDs. + +```java +var resources = new MachineResources(this, this::facing) + .items(inputGroup, items) + .items(outputGroup, items) + .fluids(coolantGroup, fluids) + .energy(energyGroup, energy) + .recipeInputs(MyRecipes.PROCESSING, "input", "coolant") + .build(); +automation = resources.automation(); +features.add(new RecipeProcessor("work", MyRecipes.PROCESSING, resources.recipes())); +``` + +Here the supplied groups have IDs `input`, `output`, `coolant` and `energy`. The resulting recipe +inventory uses those IDs as keys. `recipeGroup("combined", "left", "right")` can define an alternate +recipe mapping over groups from the same physical storage; it creates no extra automation group. +Finish all bindings/aliases/input links before the single `build()` call. + +Validation rejects duplicate group IDs, empty/out-of-range membership, overlapping physical groups, +role/IO contradictions, and multiple physical storages for the same resource type. A group's active +transfer view is restricted to its own members. `recipes()` and `automation()` require a completed +build. `ScreenInventoryValidation.validate(this)` can validate after construction; menus repeat +validation on opening, checking real slot bounds, highlight IDs and processor-button targets. + +`recipeInputs(type, keys...)` links admission rules only to specified input groups. Items/fluids +are accepted if an input ingredient of a compatible recipe allows them; matching current amounts, +available power and output room is not required just to insert an ingredient. Compatibility means +the machine exposes all referenced input/output groups with the required resource kinds. +Several types or overlapping recipe aliases accept their union; the result is intersected with +the storage's existing rules. Unlinked decorative tanks or general storage keep their own filters. +If there is no level or no compatible recipe, a linked input accepts nothing. Lookups consult the +current recipe manager, so server reloads and client recipe synchronization update admission rules. +Product insertion and save restoration do not pass through these external recipe-input checks. + +Use `MachineInventoryLifecycle(owner, features, automation, energy)` to compose persistence and +recovery. Register storage through `.items(saveKey, items)`, `.fluids(saveKey, fluids)` and +`.energy(saveKey, energy)`. Save keys must be unique, cannot be `features` or `automation`, and +each physical storage is registered once. `BasePoweredMachine` supplies this integration and +guarantees its own `energy` binding; see [block entities](block-entities.md#basepoweredmachine). + +The lifecycle saves storages, automation and features together. Loading resets old feature plans, +loads contents, then automation and feature state. Its `tick(processingAllowed, paused, policy)` +can pause processing only, transfers only, both or neither; the separate processing gate always +applies. Owed energy refunds settle even during a pause. Destruction hands off displaced storage +and escrow before clearing ownership, releases plans and drops ordinary item contents. Normal +fluid/buffer destruction remains the machine's policy. Explicit `clear()` discards all registered +contents and resets features, including manual pause and resumable progress. + ## Define independent resource groups A `ResourceGroup` declares a stable save ID, translated name, RGB colour, `ResourceType`, @@ -60,7 +113,8 @@ cleared. Do not also drop the same addon slots separately. This hook handles tra normal tank/buffer contents remain the host's separate break policy. The companion drops a **Recovery Capsule**, a companion item carrying this data. Use it on a -companion machine to restore fluids into accepting tanks and energy into available buffer space. +companion machine to restore displaced items into accepting slots, fluids into accepting tanks +and energy into available buffer space. Partial restoration leaves the remainder in the same capsule; a fully restored capsule is consumed, including in creative mode. Unknown resource payloads remain preserved. No recovery item is registered by MMDLib itself. diff --git a/docs/machine-companion.md b/docs/machine-companion.md index 5d23dbe7..83fa0e7f 100644 --- a/docs/machine-companion.md +++ b/docs/machine-companion.md @@ -1,6 +1,6 @@ # Machine companion -The optional `mmdlib_companion` mod contains four playable examples of the machine processor. +The optional `mmdlib_companion` mod contains five playable machine examples. It uses Minecraft 1.21.1, NeoForge 21.1.247 and this branch's MMDLib. Its classes and resources live in `src/companion` and are packaged separately from the library. @@ -26,14 +26,17 @@ choose their front from placement. Walking around a machine does not change its Faces cycle between Output and Disabled. Disabled faces block both automatic delivery and external extraction; settings survive reloads. Existing sources start with all six faces enabled. -JEI is included in development runs; add `-PwithoutJei` -to run without it. +JEI and Jade are included in playable companion development runs. Add `-PwithoutJei` +to run without JEI; Jade remains installed. Neither mod is bundled into the companion jar. +Mock server GameTests exclude Jade and Curios because their fake connections cannot negotiate +those mods' login payloads; real client smoke runs include them. | Block ID (prefix `mmdlib_companion:`) | What it does | | --- | --- | | `crusher` | One input slot, two output slots, one processor, and a water/lava preview tank | | `transmuter` | One slot limited to one item; converts an iron ingot into a gold ingot | | `dual_crusher` | Two independently pausable processors share the same input and output slots | +| `logistics` | Seven independent resource groups for testing automation, filters and side configuration; no recipe processing | | `creative_power` | Refills its buffer and supplies up to 1,000 FE per enabled adjacent face per tick | The crusher recipes take 100 ticks and 2,000 FE per cycle: cobblestone becomes gravel, gravel becomes @@ -51,16 +54,24 @@ starting empty. The Dual Crusher keeps its existing shared single-output layout. The bottom of each processor panel has a compact, right-aligned row of icon buttons above the inventory, with thin dark borders and flat grey backgrounds. -Click the pause icon to interrupt a lane and release its reservations; it becomes a green play -icon while paused. Click again to resume. This works without power. The Dual Crusher has separate -buttons marked A and B; pausing one lane releases only its share, and the other can continue. -The redstone button cycles **Ignore / Run when powered / Run when unpowered**. Redstone holds -progress and energy use while keeping reservations; it never overrides manual pause. Both the -selected redstone mode and each lane's pause state survive save/load. Hover a button for its state -and action. The yellow info tab opens a matching drawer to the left with machine guidance and -any current power-stall or blocked-finish warnings. Click the icon again to close it; scroll -inside to read longer text. All four screens place a vertical battery on the right, using the -reusable `` XML widget. Its red charge bars fill upwards inside a slim casing. +The generic processor button shows its **current** state: pause when manually paused, stop while +working, play while idle. Click to unpause, stop and release reservations, or pause idle work, +respectively. Resumable progress is retained. The Dual Crusher targets each lane explicitly; +pausing one releases only its share. Single-processor machines inherit a keyless button from the base. + +Redstone face settings control whether processing may run. A redstone hold keeps progress and +reservations; manual pause takes priority and is never undone by a signal change. With input +watching configured, the drawer icon adds a green play or yellow pause overlay for the resolved +gate. A small red dot on a processor button marks disagreement with manual pause; its compact +multiline tooltip explains which setting prevents work. Settings and manual pause survive saves. +See [redstone face controls](gui-screens.md#redstone-face-drawer) for the per-face modes and ranges. + +Every screen inherits the yellow Info popout from `mmdlib:base_powered_machine`. Its translation is +`{machineId}.info`, such as `mmdlib_companion:transmuter.info`, plus dynamic warnings from the base +entity. The base reports processor power stalls, blocked finishes and host holds; Crusher can also +report its configured energy reserve. Click the icon to close the drawer; scroll longer text. +All five screens place a vertical battery on the right using ``. +Its red charge bars fill upwards inside a slim casing. The energy and fluid gauges share matching edges and medium-grey empty backgrounds, darker than slots. Energy bars are thin with a soft reflection; cylindrical glass shading covers only the fluid. The Crusher centers its input, a circular progress dial and two adjacent output slots. The dial is a @@ -98,7 +109,9 @@ fully before switching between water and lava. Rejected bucket transfers leave t The tank saves with the machine and is only a display example; crushing recipes do not consume it. Breaking the machine discards its fluid. -Hoppers and pipes can insert into input slots and extract from output slots on any face. The +Hoppers and pipes obey each group's configured side modes and storage roles. Recipe-linked +inputs accept only ingredients allowed by compatible recipes; decorative tanks retain their own +filters. Recipe reloads update those admission rules. The converter's single slot permits both operations, subject to reservations. Its capacity of one is deliberate: leftover iron would prevent a gold product from fitting. Remove the gold before adding the next iron. Because the converter shares one slot for input and output, an extracting hopper can @@ -115,6 +128,7 @@ including reserved inputs. Normal `/setblock` and `/fill` replacement clears con ```sh ./gradlew build companionJar ./gradlew check +# Optional full graphical check; normally covered by CI: ./gradlew runCompanionClientSmoke ``` @@ -127,8 +141,8 @@ publication. Release uploads select only the exact library, sources and Javadoc the tag. Run `./gradlew clean` when updating a checkout that still has companion jars in `build/libs`; packaging verification rejects those stale jars. -`check` includes the companion's thirteen server game tests and checks both jars for leaked classes and -resources. `runCompanionGameTestServer` runs just those tests. They cover all four registrations and +`check` includes the companion server game tests and checks both jars for leaked classes and +resources. `runCompanionGameTestServer` runs just those tests. They cover all five registrations and menus, recipe loading, adjacent power, the one-slot limit and interruption through the menu, two shared lanes across save/load, command replacement versus destruction, tank capacity and persistence, refusal to mix fluids, bucket filling/draining through the block interaction, and cursor-container @@ -139,7 +153,7 @@ Redstone tests cover all modes on all three processors, signal changes, independ retained reservations, saved settings, and safe defaults for older or invalid saves. `runCompanionClientSmoke` creates a separate test world under `build/companion-client-smoke`, opens -all four menus over the network and captures them under its `screenshots` directory. It is separate +all five menus over the network and captures them under its `screenshots` directory. It is separate from `check` because it needs a graphical client. It also accepts `-PwithoutJei`. The shared `build_status.yml` workflow runs `check`, builds both jars, and runs the companion and library GUI smoke tests. The library smoke is exercised with and without JEI. @@ -148,11 +162,27 @@ It also clicks the gauge to drain a lava bucket and pour it back, verifying curs The client smoke also clicks pause and redstone controls and waits for their synchronized states; `crusher-paused.png` and `crusher-redstone.png` capture their alternate icons. +All five entities extend `BasePoweredMachine`, and all five XML screens extend the library base. +Creative Power and Logistics remove the inherited processor-control area with an empty fill. +Crusher replaces the body to retain its settings context. The shared Info popup is never duplicated +in a child screen; each machine supplies its own language entry. + +Stable slot/tank IDs preserve contents when definitions move. Displaced contents remain in recovery +and are handed to the companion's Recovery Capsule on removal; use the capsule on a machine to +restore items, fluids or power, retaining any remainder. Ordinary fluid and energy destruction is +still separate from displaced-content recovery. See [storage recovery](capabilities.md#stable-storage-identities-and-recovery). + +For local edits, run focused XML/layout/processor tests and relevant server GameTests. Do not run +the full UI smoke suite after every edit or PR update; CI supplies full GUI coverage. Run it locally +when explicitly requested or when a broad regression needs it. The base regression fixture also +checks omitted power registration, common warnings, custom client packets and world-save isolation. + ## Code to read - `CompanionMod`: registers blocks, items, block-entity types, recipe types, capabilities and actions. - `DemoBlock`: block creation, server ticking, menu opening and the contents-drop hook. -- `ProcessorEntity`: storages, group keys, shared lanes, saving after storages, and feature sync. +- `BasePoweredMachine` (in MMDLib): owned power, common ticking/persistence, warnings and client-sync hooks. +- `ProcessorEntity`: resource groups, recipe-linked inputs, shared lanes and machine-specific metadata. - `CreativePowerEntity`: the external energy capability and adjacent transfer. - `src/companion/resources/mmdlib/screen`: XML screens, bound to public getters at startup. - `src/companion/resources/data/mmdlib_companion/recipe`: data-driven machine recipes. diff --git a/docs/mod-author-guide.md b/docs/mod-author-guide.md index 70423559..6d296282 100644 --- a/docs/mod-author-guide.md +++ b/docs/mod-author-guide.md @@ -448,6 +448,15 @@ optional mods when their requiredMod gate is set. ## 8. Verify in a running mod +For code-defined powered machines, [BasePoweredMachine](block-entities.md#basepoweredmachine) +provides energy, feature lifecycle, warnings and client-sync hooks. Define resources with +[MachineResources](inventory-automation.md#shared-machine-resource-definitions), including stable +slot/tank identities and recipe-linked input filtering, then extend the library's +[`mmdlib:base_powered_machine` screen](gui-screens.md#library-powered-machine-screen). The consuming +mod still registers blocks/types/capabilities, supplies recipes and machine-specific inventories, +and preserves displaced contents through its recovery callback. The +[companion](machine-companion.md) demonstrates all of these together. + The MMDLib build checks definition codecs and planning. Your consuming mod still needs to verify registration, resource reloads, harvesting levels, repair tags, scythe protection events, crackhammer counts/durability, all block placement states, diff --git a/gradle.properties b/gradle.properties index 37d916dd..bee48734 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,6 +36,7 @@ curios_version = 9.5.1+1.21.1 curios_version_range = [9.5.1,10) sophisticated_backpacks_version = 1.21.1-3.26.20.2244 sophisticated_core_version = 1.21.1-1.5.28.2458 +jade_version = 15.10.6+neoforge # Gradle org.gradle.jvmargs = -Xmx3G diff --git a/scripts/companion.gradle b/scripts/companion.gradle index 161c0953..bcd58c75 100644 --- a/scripts/companion.gradle +++ b/scripts/companion.gradle @@ -7,7 +7,7 @@ sourceSets { compileClasspath += sourceSets.companion.output + sourceSets.companion.compileClasspath runtimeClasspath += sourceSets.companion.output + sourceSets.companion.runtimeClasspath } - // Mock GameTest connections cannot negotiate Curios' login-sync payloads. + // Mock GameTest connections cannot negotiate Curios/Jade login-sync payloads. // Reuse the compiled fixtures, but keep optional mods for real client/server smoke runs. companionServerTest { runtimeClasspath += sourceSets.companionTest.output + sourceSets.companion.output + sourceSets.main.output + sourceSets.main.runtimeClasspath @@ -18,6 +18,12 @@ neoForge.addModdingDependenciesTo(sourceSets.companion) neoForge.addModdingDependenciesTo(sourceSets.companionTest) neoForge.addModdingDependenciesTo(sourceSets.companionServerTest) +// Jade is present in playable companion and real-client smoke runs, including their integrated server. +// Keep mock GameTest connections isolated, as with Curios below; publish neither runtime fixture. +dependencies { + companionRuntimeOnly "maven.modrinth:jade:${jade_version}" +} + // Development fixtures only: do not add backpack APIs or required mod metadata to either jar. // -PwithoutBackpacks retains a companion run without Curios or backpack mods. if (!providers.gradleProperty('withoutBackpacks').isPresent()) { diff --git a/scripts/generate_gui_sprites.py b/scripts/generate_gui_sprites.py index 55e930b7..6382ba63 100644 --- a/scripts/generate_gui_sprites.py +++ b/scripts/generate_gui_sprites.py @@ -275,17 +275,44 @@ def glyph(img, rows, x, y, ink): if bit != ".": img[y + dy][x + dx] = ink pause = ["##...##"] * 9 + stop = ["#######"] * 7 play = ["#......", "###....", "#####..", "#######", "#######", "#####..", "###....", "#......"] letters = {"a": [".#.", "#.#", "###", "#.#", "#.#"], "b": ["##.", "#.#", "##.", "#.#", "##."]} - for name, rows, ink in (("pause", pause, (240, 198, 70, 255)), + for name, rows, ink in (("stop", stop, (232, 86, 72, 255)), + ("pause", pause, (240, 198, 70, 255)), ("resume", play, (100, 216, 112, 255))): - for lane in ("", "a", "b"): + for lane in (("",) if name == "stop" else ("", "a", "b")): img = canvas(16, 16) glyph(img, rows, 3 if lane else 4, 2 if lane else 3, ink) if lane: glyph(img, letters[lane], 11, 9, grey(245)) write_png("icon_" + name + ("_" + lane if lane else ""), img) + # Native 12px sprites for the compact processor controls: no 16-to-12 scaling. + # All three glyphs, including their shadow, occupy the same centered 8x8 box. + for name, rows, ink in ( + ("pause", ["##...##"] * 7, (240, 198, 70, 255)), + ("stop", ["#######"] * 7, (232, 86, 72, 255)), + ("play", ["#......", "###....", "#####..", "#######", "#####..", "###....", "#......"], (100, 216, 112, 255)), + ): + img = canvas(12, 12) + glyph(img, rows, 2, 2, ink) + write_png("icon_processor_" + name, img) + + for name, rows, ink in ( + ("running", ["#....", "###..", "#####", "###..", "#...."], (100, 216, 112, 255)), + ("paused", ["##.##"] * 5, (240, 198, 70, 255)), + ): + img = canvas(7, 7) + glyph(img, rows, 0, 0, ink) + write_png("icon_redstone_" + name, img) + + img = canvas(5, 5) + for y, row in enumerate([".###.", "#####", "#####", "#####", ".###."]): + for x, bit in enumerate(row): + if bit == "#": img[y][x] = (244, 55, 55, 255) + write_png("icon_processor_conflict", img) + torch = [".###.", "#####", "#####", ".###.", "..#..", "..#..", "..#..", "..#..", "..#.."] badges = {"ignore": ["#.#", ".#.", "#.#"], "high": [".#.", "###", ".#."], "low": ["...", "###", "..."]} diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java index 187adaeb..3952c106 100644 --- a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java +++ b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java @@ -29,6 +29,7 @@ import zone.moddev.mc.lib.api.MaterialExtensions; import zone.moddev.mc.lib.api.capabilities.MachineCapabilities; import zone.moddev.mc.lib.gui.runtime.ScreenClick; +import zone.moddev.mc.lib.machine.neoforge.BasePoweredMachine; import zone.moddev.mc.lib.machine.neoforge.MachineRecipeTypes; import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; @@ -92,18 +93,15 @@ public CompanionMod(IEventBus bus) { id("tank_container"), MaterialEventTypes.GUI_CLICK, CompanionMod::transferTankContainer); - for (int lane = 0; lane < 2; lane++) { - int index = lane; - MaterialExtensions.GLOBAL - .events() - .registerCallback( - id("toggle_" + lane), - MaterialEventTypes.GUI_CLICK, - click -> { - if (click.menu().blockEntity instanceof ProcessorEntity machine) - machine.toggle(index); - }); - } + // The custom progress dial remains clickable; processor buttons have a library-owned action. + MaterialExtensions.GLOBAL + .events() + .registerCallback( + id("toggle_0"), + MaterialEventTypes.GUI_CLICK, + click -> { + if (click.menu().blockEntity instanceof ProcessorEntity machine) machine.toggle(0); + }); } private static void transferTankContainer(ScreenClick click) { @@ -190,7 +188,7 @@ public static ResourceLocation id(String path) { /** Each block has its own type and XML screen, while processors share one implementation. */ public static final class Machine { public final DeferredHolder block; - public final DeferredHolder, BlockEntityType> type; + public final DeferredHolder, BlockEntityType> type; private final ProcessorEntity.Kind kind; private Machine(String name, ProcessorEntity.Kind kind) { @@ -208,11 +206,11 @@ private Machine(String name, ProcessorEntity.Kind kind) { ITEMS.register(name, () -> new BlockItem(block.get(), new Item.Properties())); } - private BlockEntityType entityType() { + private BlockEntityType entityType() { return type.get(); } - private DemoEntity create(BlockPos pos, BlockState state) { + private BasePoweredMachine create(BlockPos pos, BlockState state) { return kind == null ? new CreativePowerEntity(type.get(), pos, state) : new ProcessorEntity(type.get(), pos, state, kind); diff --git a/src/companion/java/zone/moddev/mc/companion/CreativePowerEntity.java b/src/companion/java/zone/moddev/mc/companion/CreativePowerEntity.java index c2b68d86..3167c694 100644 --- a/src/companion/java/zone/moddev/mc/companion/CreativePowerEntity.java +++ b/src/companion/java/zone/moddev/mc/companion/CreativePowerEntity.java @@ -2,10 +2,8 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; -import net.minecraft.core.HolderLookup; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; -import net.minecraft.world.Clearable; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockState; @@ -18,18 +16,17 @@ import zone.moddev.mc.lib.api.capabilities.IoMode; import zone.moddev.mc.lib.api.capabilities.ItemStorage; import zone.moddev.mc.lib.api.capabilities.SideConfig; -import zone.moddev.mc.lib.common.runtime.DropsContents; -import zone.moddev.mc.lib.gui.runtime.ScreenHost; +import zone.moddev.mc.lib.machine.neoforge.BasePoweredMachine; +import zone.moddev.mc.lib.machine.neoforge.MachineInventoryLifecycle; /** Refills each tick and supplies up to 1,000 FE per adjacent face per tick. */ -public final class CreativePowerEntity extends DemoEntity - implements ScreenHost, DropsContents, Clearable { +public final class CreativePowerEntity extends BasePoweredMachine { private final ItemStorage empty = new ItemStorage(0); public final AutomationInventory automation; + private final MachineInventoryLifecycle inventoryLifecycle; public CreativePowerEntity(BlockEntityType type, BlockPos pos, BlockState state) { - super(type, pos, state); - energy.setRates(0, 1_000); + super(type, pos, state, 100_000, 0, 1_000); energy.setStored(energy.capacity()); var group = new ResourceGroup<>( @@ -41,13 +38,22 @@ public CreativePowerEntity(BlockEntityType type, BlockPos pos, BlockState sta java.util.List.of(0), java.util.List.of("energy"), new SideConfig(IoMode.OUTPUT)); - group.bind(this::facing, side -> energy.view(() -> group.mode(side))); - automation = new AutomationInventory(this, java.util.List.of(group)); + automation = + new zone.moddev.mc.lib.machine.neoforge.MachineResources(this, this::facing) + .energy(group, energy) + .build() + .automation(); + inventoryLifecycle = poweredInventory(automation); + zone.moddev.mc.lib.gui.runtime.ScreenInventoryValidation.validate(this); } @Override - public void serverTick() { + protected void beforeMachineTick() { energy.insert(energy.space(), false); + } + + @Override + protected void afterMachineTick() { for (var side : Direction.values()) { if (!automation.mode(ResourceType.ENERGY, 0, side).canExtract()) continue; var target = worldPosition.relative(side); @@ -64,44 +70,23 @@ public ItemStorage screenItems() { return empty; } - @Override - public AutomationInventory screenAutomation() { - return automation; - } - @Override public boolean screenAutomationAddons() { return false; } @Override - protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { - super.saveAdditional(tag, registries); - tag.put("automation", automation.save(registries)); - } - - @Override - protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { - super.loadAdditional(tag, registries); - if (tag.contains("automation")) automation.load(tag.getCompound("automation"), registries); - } - - @Override - public void dropContents(Level level, BlockPos pos) { - automation.dropAll( - payload -> - net.minecraft.world.Containers.dropItemStack( - level, - pos.getX() + .5, - pos.getY() + .5, - pos.getZ() + .5, - RecoveryCapsuleItem.create(payload))); + protected MachineInventoryLifecycle machineInventory() { + return inventoryLifecycle; } @Override - public void clearContent() { - automation.clear(); - energy.setStored(0); - setChanged(); + protected void recoverResources(Level level, BlockPos pos, CompoundTag payload) { + net.minecraft.world.Containers.dropItemStack( + level, + pos.getX() + .5, + pos.getY() + .5, + pos.getZ() + .5, + RecoveryCapsuleItem.create(payload)); } } diff --git a/src/companion/java/zone/moddev/mc/companion/DemoBlock.java b/src/companion/java/zone/moddev/mc/companion/DemoBlock.java index 93bf4fa2..85c947ed 100644 --- a/src/companion/java/zone/moddev/mc/companion/DemoBlock.java +++ b/src/companion/java/zone/moddev/mc/companion/DemoBlock.java @@ -28,14 +28,14 @@ import net.neoforged.neoforge.fluids.FluidUtil; import zone.moddev.mc.lib.common.runtime.DropsContents; -import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.machine.neoforge.BasePoweredMachine; /** Explicit block hooks for a code-registered machine. */ public final class DemoBlock extends Block implements EntityBlock { public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING; - private final Supplier> type; + private final Supplier> type; - public DemoBlock(Properties properties, Supplier> type) { + public DemoBlock(Properties properties, Supplier> type) { super(properties); this.type = type; registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH)); @@ -86,7 +86,7 @@ public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { public BlockEntityTicker getTicker( Level level, BlockState state, BlockEntityType requested) { if (level.isClientSide() || requested != type.get()) return null; - return (world, pos, blockState, entity) -> ((DemoEntity) entity).serverTick(); + return (world, pos, blockState, entity) -> ((BasePoweredMachine) entity).serverTick(); } @Override @@ -122,7 +122,8 @@ protected ItemInteractionResult useItemOn( @Override protected InteractionResult useWithoutItem( BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { - return ScreenRuntime.open(player, level.getBlockEntity(pos)) + return level.getBlockEntity(pos) instanceof BasePoweredMachine machine + && machine.openScreen(player) ? InteractionResult.sidedSuccess(level.isClientSide()) : InteractionResult.PASS; } diff --git a/src/companion/java/zone/moddev/mc/companion/DemoEntity.java b/src/companion/java/zone/moddev/mc/companion/DemoEntity.java deleted file mode 100644 index 23f28794..00000000 --- a/src/companion/java/zone/moddev/mc/companion/DemoEntity.java +++ /dev/null @@ -1,48 +0,0 @@ -package zone.moddev.mc.companion; - -import net.minecraft.core.BlockPos; -import net.minecraft.core.HolderLookup; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.entity.BlockEntityType; -import net.minecraft.world.level.block.state.BlockState; - -import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; - -public abstract class DemoEntity extends BlockEntity { - public final EnergyBuffer energy = new EnergyBuffer(100_000, 1_000, 0).onChange(this::setChanged); - - protected DemoEntity(BlockEntityType type, BlockPos pos, BlockState state) { - super(type, pos, state); - } - - public abstract void serverTick(); - - public net.minecraft.core.Direction facing() { - return zone.moddev.mc.lib.api.capabilities.RelativeFace.facing(getBlockState()); - } - - public java.util.List screenStats() { - return java.util.List.of(energy); - } - - public long getEnergy() { - return energy.stored(); - } - - public long getEnergyCapacity() { - return energy.capacity(); - } - - @Override - protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { - super.saveAdditional(tag, registries); - tag.put("energy", energy.serializeNBT(registries)); - } - - @Override - protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { - super.loadAdditional(tag, registries); - if (tag.contains("energy")) energy.deserializeNBT(registries, tag.get("energy")); - } -} diff --git a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java index 0bf84580..337127b7 100644 --- a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java +++ b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java @@ -6,14 +6,8 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.HolderLookup; import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtOps; import net.minecraft.network.chat.Component; -import net.minecraft.network.protocol.Packet; -import net.minecraft.network.protocol.game.ClientGamePacketListener; -import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.Clearable; -import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockState; @@ -28,18 +22,13 @@ import zone.moddev.mc.lib.api.capabilities.Role; import zone.moddev.mc.lib.api.redstone.RedstoneControl; import zone.moddev.mc.lib.api.redstone.RedstoneOutput; -import zone.moddev.mc.lib.common.runtime.DropsContents; -import zone.moddev.mc.lib.gui.runtime.ScreenHost; -import zone.moddev.mc.lib.machine.feature.FeatureHost; import zone.moddev.mc.lib.machine.feature.RecipeProcessor; -import zone.moddev.mc.lib.machine.inventory.MachineInventory; -import zone.moddev.mc.lib.machine.inventory.SlotPlan; -import zone.moddev.mc.lib.machine.neoforge.FeatureSync; -import zone.moddev.mc.lib.machine.neoforge.StorageGroups; +import zone.moddev.mc.lib.machine.neoforge.BasePoweredMachine; +import zone.moddev.mc.lib.machine.neoforge.MachineInventoryLifecycle; +import zone.moddev.mc.lib.machine.neoforge.MachineResources; /** The dual crusher deliberately runs two lanes over the same input and output groups. */ -public final class ProcessorEntity extends DemoEntity - implements ScreenHost, DropsContents, Clearable { +public final class ProcessorEntity extends BasePoweredMachine { public enum Kind { CRUSHER, TRANSMUTER, @@ -49,10 +38,10 @@ public enum Kind { public final ItemStorage items; public final AutomationInventory automation; + public final MachineResources resources; + public final MachineInventoryLifecycle inventoryLifecycle; public final FluidStorage fluids = new FluidStorage().onChange(this::setChanged); public final List lanes; - public final FeatureHost features = - new FeatureHost(this::setChanged, () -> FeatureSync.send(this)); private final Kind kind; public final RedstoneControl redstone; private String machineName = "Crusher"; @@ -92,45 +81,30 @@ public ProcessorEntity(BlockEntityType type, BlockPos pos, BlockState state, if (kind == Kind.LOGISTICS) for (int i = 0; i < 4; i++) fluids.addTank(4000, i < 2 ? Role.INPUT : Role.OUTPUT); int[] outputSlots = kind == Kind.CRUSHER ? new int[] {1, 2} : new int[] {1}; - var inventory = MachineInventory.builder().group("energy", StorageGroups.energy(energy)); + resources = new MachineResources(this, this::facing); if (kind == Kind.TRANSMUTER) { // One key, and one item: the recipe consumes the whole slot before inserting gold. items = new ItemStorage(1).limit(1, 0).onChange(this::setChanged); - inventory.group("slot", StorageGroups.items(items, 0)); } else { items = new ItemStorage(1 + outputSlots.length) .role(Role.INPUT, 0) .role(kind == Kind.CRUSHER ? Role.STORAGE : Role.OUTPUT, outputSlots) .onChange(this::setChanged); - inventory - .group("input", StorageGroups.items(items, 0)) - .group("output", StorageGroups.items(items, outputSlots)); } - var shared = inventory.build(); var recipeType = kind == Kind.TRANSMUTER ? CompanionMod.TRANSMUTING : CompanionMod.CRUSHING; - var processors = new ArrayList(); - for (int i = 0; i < (kind == Kind.DUAL ? 2 : 1); i++) { - var processor = - new RecipeProcessor( - "lane_" + i, - recipeType, - shared, - Component.translatable("screen.mmdlib_companion.processor", i + 1)); - processors.add(processor); - features.add(processor); - } - lanes = List.copyOf(processors); - var groups = new ArrayList>(); - groups.add( - itemGroup("input", 0x568bd8, kind == Kind.TRANSMUTER ? IoMode.BOTH : IoMode.INPUT, 0)); + resources.items( + itemGroup("input", 0x568bd8, kind == Kind.TRANSMUTER ? IoMode.BOTH : IoMode.INPUT, 0), + items); if (kind != Kind.TRANSMUTER) - groups.add( + resources.items( itemGroup( - "output", 0xe48f45, kind == Kind.CRUSHER ? IoMode.BOTH : IoMode.OUTPUT, outputSlots)); + "output", 0xe48f45, kind == Kind.CRUSHER ? IoMode.BOTH : IoMode.OUTPUT, outputSlots), + items); + items.slotId(0, "input"); + for (int i = 1; i < items.getSlots(); i++) items.slotId(i, "output_" + i); int[] colours = {0x64bfc1, 0xa678cb, 0xe0c55d, 0xce668e}; for (int i = 0; i < fluids.getTanks(); i++) { - final int tank = i; var group = new ResourceGroup<>( "tank_" + i, @@ -142,10 +116,8 @@ public ProcessorEntity(BlockEntityType type, BlockPos pos, BlockState state, List.of(i == 0 ? "tank" : "tank_" + i), new SideConfig( kind == Kind.CRUSHER ? IoMode.BOTH : (i < 2 ? IoMode.INPUT : IoMode.OUTPUT))); - group.bind( - this::facing, - side -> fluids.view(index -> index == tank ? group.mode(side) : IoMode.DISABLED)); - groups.add(group); + resources.fluids(group, fluids); + fluids.tankId(i, group.id()); } var energyGroup = new ResourceGroup<>( @@ -158,11 +130,28 @@ public ProcessorEntity(BlockEntityType type, BlockPos pos, BlockState state, List.of("energy"), new SideConfig(kind == Kind.LOGISTICS ? IoMode.BOTH : IoMode.INPUT)); if (kind == Kind.LOGISTICS) energy.setRates(1000, 1000); - energyGroup.bind(this::facing, side -> energy.view(() -> energyGroup.mode(side))); - groups.add(energyGroup); - automation = new AutomationInventory(this, groups); + resources.energy(energyGroup, energy); + if (kind == Kind.TRANSMUTER) resources.recipeGroup("slot", "input"); + if (kind != Kind.LOGISTICS) + resources.recipeInputs(recipeType, kind == Kind.TRANSMUTER ? "slot" : "input"); + resources.build(); + automation = resources.automation(); + var processors = new ArrayList(); + for (int i = 0; i < (kind == Kind.DUAL ? 2 : 1); i++) { + var processor = + new RecipeProcessor( + "lane_" + i, + recipeType, + resources.recipes(), + Component.translatable("screen.mmdlib_companion.processor", i + 1)); + processors.add(processor); + features.add(processor); + } + lanes = List.copyOf(processors); + inventoryLifecycle = + poweredInventory(automation).items("items", items).fluids("fluids", fluids); var outputParts = new ArrayList(); - for (var group : groups) + for (var group : automation.groups()) outputParts.add( RedstoneOutput.resource( group, @@ -190,6 +179,7 @@ public ProcessorEntity(BlockEntityType type, BlockPos pos, BlockState state, })); if (kind != Kind.LOGISTICS) outputParts.add(features); redstone = new RedstoneControl(this, outputParts); + zone.moddev.mc.lib.gui.runtime.ScreenInventoryValidation.validate(this); } private ResourceGroup itemGroup( @@ -206,9 +196,7 @@ private ResourceGroup itemGroup( .mapToObj(i -> i == 0 ? id : id + "_" + (i + 1)) .toList(), new SideConfig(mode)); - return group.bind( - this::facing, - side -> items.view(index -> group.contains(index) ? group.mode(side) : IoMode.DISABLED)); + return group; } @Override @@ -216,22 +204,6 @@ public RedstoneControl screenRedstone() { return redstone; } - public AutomationInventory screenAutomation() { - return automation; - } - - @Override - public void serverTick() { - redstone.readInputs(); - features.tick( - level, - kind != Kind.LOGISTICS - && redstoneAllowsRun() - && energy.stored() >= energy.capacity() * reservePercent / 100); - AutomationTransfers.tick(automation, energy); - redstone.updateOutputs(); - } - public FluidStack getTank1() { return fluids.getFluidInTank(1); } @@ -244,30 +216,10 @@ public FluidStack getTank3() { return fluids.getFluidInTank(3); } - private boolean redstoneAllowsRun() { - return !redstone.paused(); - } - public void toggle(int index) { if (index < 0 || index >= lanes.size()) return; var processor = lanes.get(index); - if (processor.paused()) processor.resume(); - else processor.interrupt(); - // Redstone can hold the feature tick, so persist and sync a manual toggle immediately. - setChanged(); - FeatureSync.send(this); - } - - public int getProgress() { - return lanes.getFirst().progress(); - } - - @Override - public List screenStats() { - var parts = new ArrayList(); - parts.add(energy); - parts.addAll(lanes); - return List.copyOf(parts); + features.setProcessorPaused(processor.key(), !processor.paused()); } public boolean hasTank() { @@ -282,10 +234,6 @@ public int getTankCapacity() { return fluids.getTankCapacity(0); } - public int getTime() { - return lanes.getFirst().time(); - } - public int getSecondProgress() { return lanes.size() == 2 ? lanes.get(1).progress() : 0; } @@ -294,34 +242,12 @@ public int getSecondTime() { return lanes.size() == 2 ? lanes.get(1).time() : 0; } - public boolean getPaused() { - return lanes.getFirst().paused(); - } - - public boolean getSecondPaused() { - return lanes.size() == 2 && lanes.get(1).paused(); - } - + @Override public List getWarnings() { - var warnings = new ArrayList(); + var warnings = new ArrayList(super.getWarnings()); if (energy.stored() < energy.capacity() * reservePercent / 100) warnings.add( Component.translatable("screen.mmdlib_companion.reserve.waiting", reservePercent)); - if (!redstoneAllowsRun()) - warnings.add(Component.translatable("screen.mmdlib_companion.redstone.waiting")); - for (int i = 0; i < lanes.size(); i++) { - var status = lanes.get(i).status(); - if (status == RecipeProcessor.Status.STALLED - || status == RecipeProcessor.Status.BLOCKED - || status == RecipeProcessor.Status.HELD) - warnings.add( - Component.translatable("screen.mmdlib_companion.lane_" + (i == 0 ? "a" : "b")) - .append(": ") - .append( - Component.translatable( - "status.mmdlib_companion." - + status.name().toLowerCase(java.util.Locale.ROOT)))); - } return warnings; } @@ -337,53 +263,13 @@ public int[] screenRecipeSlots(ResourceLocation category) { } @Override - public void dropContents(Level level, BlockPos pos) { - items.dropAll(level, pos); - automation.dropAll( - payload -> - net.minecraft.world.Containers.dropItemStack( - level, - pos.getX() + .5, - pos.getY() + .5, - pos.getZ() + .5, - RecoveryCapsuleItem.create(payload))); - } - - @Override - public void clearContent() { - automation.clear(); - lanes.forEach(RecipeProcessor::reset); - for (int slot = 0; slot < items.getSlots(); slot++) { - items.setPlan(slot, SlotPlan.NONE); - items.setStackInSlot(slot, ItemStack.EMPTY); - } - energy.setStored(0); - for (int i = 0; i < fluids.getTanks(); i++) fluids.tank(i).setFluid(FluidStack.EMPTY); - setChanged(); - FeatureSync.send(this); - } - - @Override - protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { - super.saveAdditional(tag, registries); - tag.put("items", items.serializeNBT(registries)); - tag.put("automation", automation.save(registries)); - tag.put("redstone_control", redstone.save(registries)); + protected void saveMachineData(CompoundTag tag, HolderLookup.Provider registries) { tag.putString("machine_name", machineName); tag.putInt("reserve_percent", reservePercent); - if (hasTank()) tag.put("fluids", fluids.serializeNBT(registries)); - tag.put("features", features.save(registries.createSerializationContext(NbtOps.INSTANCE))); } @Override - protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { - if (FeatureSync.read(features, tag, registries)) return; - super.loadAdditional(tag, registries); - int savedMode = tag.getInt("redstone_mode"); - int redstoneMode = savedMode >= 0 && savedMode <= 2 ? savedMode : 0; - if (tag.contains("redstone_control")) - redstone.load(tag.getCompound("redstone_control"), registries); - else redstone.migrate(redstoneMode); + protected void loadMachineData(CompoundTag tag, HolderLookup.Provider registries) { String savedName = tag.getString("machine_name"); machineName = !savedName.isBlank() @@ -392,20 +278,25 @@ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) ? savedName.strip() : "Crusher"; reservePercent = Math.clamp(tag.getInt("reserve_percent"), 0, 100); - items.deserializeNBT(registries, tag.getCompound("items")); - automation.load(tag.getCompound("automation"), registries); - if (hasTank() && tag.contains("fluids")) - fluids.deserializeNBT(registries, tag.getCompound("fluids")); - features.load(registries.createSerializationContext(NbtOps.INSTANCE), tag.get("features")); } @Override - public CompoundTag getUpdateTag(HolderLookup.Provider registries) { - return FeatureSync.updateTag(features, registries); + protected MachineInventoryLifecycle machineInventory() { + return inventoryLifecycle; } @Override - public Packet getUpdatePacket() { - return ClientboundBlockEntityDataPacket.create(this); + protected boolean processingAllowed() { + return kind != Kind.LOGISTICS && energy.stored() >= energy.capacity() * reservePercent / 100; + } + + @Override + protected void recoverResources(Level level, BlockPos pos, CompoundTag payload) { + net.minecraft.world.Containers.dropItemStack( + level, + pos.getX() + .5, + pos.getY() + .5, + pos.getZ() + .5, + RecoveryCapsuleItem.create(payload)); } } diff --git a/src/companion/java/zone/moddev/mc/companion/RecoveryCapsuleItem.java b/src/companion/java/zone/moddev/mc/companion/RecoveryCapsuleItem.java index 333e043e..5397a585 100644 --- a/src/companion/java/zone/moddev/mc/companion/RecoveryCapsuleItem.java +++ b/src/companion/java/zone/moddev/mc/companion/RecoveryCapsuleItem.java @@ -52,7 +52,19 @@ public static boolean restore(ItemStack stack, ProcessorEntity target) { var entry = resources.getCompound(i); var data = entry.getCompound("data"); String type = entry.getString("type"); - if (type.equals(ResourceType.FLUIDS.id().toString())) { + if (type.equals(ResourceType.ITEMS.id().toString())) { + var item = + ItemStack.parseOptional(target.getLevel().registryAccess(), data.getCompound("item")); + if (!item.isEmpty()) { + var remaining = + zone.moddev.mc.lib.api.capabilities.Transfer.insert(target.items, item, false); + if (remaining.getCount() < item.getCount()) { + changed = true; + if (remaining.isEmpty()) data.remove("item"); + else data.put("item", remaining.save(target.getLevel().registryAccess())); + } + } + } else if (type.equals(ResourceType.FLUIDS.id().toString())) { var fluid = FluidStack.parseOptional(target.getLevel().registryAccess(), data.getCompound("fluid")); int moved = target.fluids.fill(fluid, IFluidHandler.FluidAction.EXECUTE); diff --git a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json index 0020c1a4..7ce3e7fd 100644 --- a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json +++ b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json @@ -15,16 +15,12 @@ "screen.mmdlib_companion.redstone.high": "Run when powered", "screen.mmdlib_companion.redstone.low": "Run when unpowered", "screen.mmdlib_companion.redstone.tip": "Redstone: {value}. Click to cycle Ignore / Powered / Unpowered.", - "screen.mmdlib_companion.redstone.waiting": "Waiting for redstone", "screen.mmdlib_companion.lane_a": "Lane A", "screen.mmdlib_companion.lane_b": "Lane B", "screen.mmdlib_companion.single": "One iron in, one gold out", "screen.mmdlib_companion.power": "Powers adjacent blocks on enabled sides", - "screen.mmdlib_companion.info.creative_power": "*Creative Power Source*\n\nSupplies up to 1,000 FE per enabled face each tick.\n\nUse the side configuration panel to enable or disable outputs. Statistics shows the measured output rate.", + "mmdlib_companion:creative_power.info": "*Creative Power Source*\n\nSupplies up to 1,000 FE per enabled face each tick.\n\nUse the side configuration panel to enable or disable outputs. Statistics shows the measured output rate.", "screen.mmdlib_companion.rate": "1,000 FE per face per tick", - "status.mmdlib_companion.stalled": "Needs power", - "status.mmdlib_companion.blocked": "Blocked", - "status.mmdlib_companion.held": "Held by machine controls", "block.mmdlib_companion.crusher": "Crusher", "block.mmdlib_companion.transmuter": "Iron-to-Gold Converter", "block.mmdlib_companion.dual_crusher": "Dual Crusher", @@ -49,9 +45,9 @@ "screen.mmdlib_companion.recipes.tip": "Show crushing recipes in JEI, when installed.", "screen.mmdlib_companion.tank.tip": "Water or lava: {value} / {max} mB. Click with a fluid container to transfer.", "screen.mmdlib_companion.tank.refused": "Cannot transfer fluid. The tank holds water or lava, one type at a time, up to 4 buckets.", - "screen.mmdlib_companion.info.crusher": "*Crusher*\n\nCrush ore into raw materials, or stone into gravel and sand.\n\nPlace a power source beside the machine. Pause to release reserved items.\n\nThe left tank holds 4 buckets of water or lava for display. Click the gauge with a fluid container on the cursor to fill or drain it, or right-click the block with a bucket. Empty the tank before changing fluids. Crushing does not use the fluid.", - "screen.mmdlib_companion.info.transmuter": "*Iron to gold*\n\nInsert one iron ingot. The same slot holds the gold when the cycle finishes.\n\nSupply power from an adjacent block. Pause to release the input.", - "screen.mmdlib_companion.info.dual_crusher": "*Dual crusher*\n\nTwo lanes share the input and output slots. Each lane reserves its own items and space.\n\nPause either lane independently to release its share.", + "mmdlib_companion:crusher.info": "*Crusher*\n\nCrush ore into raw materials, or stone into gravel and sand.\n\nPlace a power source beside the machine. Pause to release reserved items.\n\nThe left tank holds 4 buckets of water or lava for display. Click the gauge with a fluid container on the cursor to fill or drain it, or right-click the block with a bucket. Empty the tank before changing fluids. Crushing does not use the fluid.", + "mmdlib_companion:transmuter.info": "*Iron to gold*\n\nInsert one iron ingot. The same slot holds the gold when the cycle finishes.\n\nSupply power from an adjacent block. Pause to release the input.", + "mmdlib_companion:dual_crusher.info": "*Dual crusher*\n\nTwo lanes share the input and output slots. Each lane reserves its own items and space.\n\nPause either lane independently to release its share.", "item.mmdlib_companion.mechanical_puller": "Mechanical Puller", "item.mmdlib_companion.mechanical_pusher": "Mechanical Pusher", "item.mmdlib_companion.input_pump": "Input Pump", @@ -63,6 +59,7 @@ "item.mmdlib_companion.tag_filter": "Tag Filter", "item.mmdlib_companion.fluid_filter": "Fluid Filter", "block.mmdlib_companion.logistics": "Logistics Workbench", + "mmdlib_companion:logistics.info": "Seven independent inventories. Install a module, enable a side, and configure its optional filter.", "screen.mmdlib_companion.group.input": "Item input", "screen.mmdlib_companion.group.output": "Item output", "screen.mmdlib_companion.group.energy": "Energy", diff --git a/src/companion/resources/mmdlib/screen/creative_power.xml b/src/companion/resources/mmdlib/screen/creative_power.xml index 889ef8e6..b692ccac 100644 --- a/src/companion/resources/mmdlib/screen/creative_power.xml +++ b/src/companion/resources/mmdlib/screen/creative_power.xml @@ -1,16 +1,12 @@ - - - - - + block-entity="zone.moddev.mc.companion.CreativePowerEntity" inventory="none" extends="mmdlib:base_powered_machine"> + + - - - - + diff --git a/src/companion/resources/mmdlib/screen/crusher.xml b/src/companion/resources/mmdlib/screen/crusher.xml index 22748392..3133e3cc 100644 --- a/src/companion/resources/mmdlib/screen/crusher.xml +++ b/src/companion/resources/mmdlib/screen/crusher.xml @@ -1,56 +1,51 @@ - - - - - - - - - - - - - - - - - - -