Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 123 additions & 9 deletions docs/block-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -164,8 +270,9 @@ BlockEntityTicker<CrusherEntity> 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.
Expand All @@ -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 `<processor-button>` (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.

Expand Down Expand Up @@ -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`:

Expand All @@ -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.
Expand Down
39 changes: 36 additions & 3 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompoundTag>)` 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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading