diff --git a/.github/workflows/build_status.yml b/.github/workflows/build_status.yml index 0803185..aa6ccda 100644 --- a/.github/workflows/build_status.yml +++ b/.github/workflows/build_status.yml @@ -50,3 +50,59 @@ jobs: retention-days: 14 path: | build/libs/*.jar + + - name: Upload test reports and game test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: check-reports + path: | + build/reports/tests/ + build/gametest/logs/ + build/companion-gametest/logs/ + if-no-files-found: ignore + + gui-smoke: + name: Client GUI smoke test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: gradle/actions/wrapper-validation@v6 + - uses: actions/setup-java@v6 + with: + distribution: 'microsoft' + java-version: ${{ env.JAVA_VERSION }} + - uses: gradle/actions/setup-gradle@v6 + + - name: Install Xvfb and Mesa + run: | + sudo apt-get update + sudo apt-get install -y xvfb libgl1-mesa-dri + + # Run the companion before the library smoke, which injects test sources into main output. + - name: Companion GUI smoke test + timeout-minutes: 20 + run: xvfb-run -a ./gradlew runCompanionClientSmoke + + - name: GUI smoke test with JEI + timeout-minutes: 20 + run: xvfb-run -a ./gradlew runClient -I scripts/gui-smoke.init.gradle + + - name: GUI smoke test without JEI + timeout-minutes: 20 + run: xvfb-run -a ./gradlew runClient -PwithoutJei -I scripts/gui-smoke.init.gradle + + - name: Upload client logs and screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: gui-smoke-logs + path: | + build/companion-client-smoke/logs/ + build/companion-client-smoke/screenshots/ + build/gui-smoke/logs/ + build/gui-smoke/screenshots/ + build/gui-smoke-without-jei/logs/ + build/gui-smoke-without-jei/screenshots/ + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index c1a213b..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: CI - -# Every pull request runs the unit tests and the server game tests (`./gradlew check`), then the -# client GUI smoke test with and without JEI. -on: - pull_request: - push: - branches: [master-2.0-1.21.1-neo] - -jobs: - check: - name: Unit tests, checks and game tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: 21 - - uses: gradle/actions/setup-gradle@v6 - - - name: Check - run: ./gradlew check --stacktrace - - - name: Upload test reports and the game test log - if: always() - uses: actions/upload-artifact@v7 - with: - name: check-reports - path: | - build/reports/tests/ - build/gametest/logs/ - if-no-files-found: ignore - - gui-smoke: - name: Client GUI smoke test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: 21 - - uses: gradle/actions/setup-gradle@v6 - - # No display: Xvfb and Mesa's software OpenGL render the client off screen. - - name: Install Xvfb and Mesa - run: | - sudo apt-get update - sudo apt-get install -y xvfb libgl1-mesa-dri - - - name: GUI smoke test with JEI - timeout-minutes: 20 - run: xvfb-run -a ./gradlew runClient -I scripts/gui-smoke.init.gradle - - - name: GUI smoke test without JEI - timeout-minutes: 20 - run: xvfb-run -a ./gradlew runClient -PwithoutJei -I scripts/gui-smoke.init.gradle - - - name: Upload the client logs and screenshots - if: always() - uses: actions/upload-artifact@v7 - with: - name: gui-smoke-logs - path: | - build/gui-smoke/logs/ - build/gui-smoke/screenshots/ - build/gui-smoke-without-jei/logs/ - build/gui-smoke-without-jei/screenshots/ - if-no-files-found: ignore diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 913e426..b95b36a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,9 +45,10 @@ jobs: - name: Read gradle.properties run: | - # Keeps the CurseForge ID defined in one place. + # Keep the CurseForge ID and archive name defined in gradle.properties. prop() { grep -E "^$1\s*=" gradle.properties | cut -d= -f2- | tr -d '[:space:]'; } echo "CURSEFORGE_ID=$(prop curseforge_id)" >> "$GITHUB_ENV" + echo "MOD_ARCHIVE_NAME=$(prop mod_archive_name)" >> "$GITHUB_ENV" - name: Build and test run: | @@ -70,10 +71,11 @@ jobs: with: github-token: ${{ github.token }} version: ${{ env.TAG }} - # Main jar first so it becomes the primary file, sources/javadoc attached as extras. + # Exact release filenames exclude companion jars and stale versions. files: | - build/libs/!(*-@(sources|javadoc)).jar - build/libs/*-@(sources|javadoc).jar + build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}.jar + build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}-sources.jar + build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}-javadoc.jar - name: Publish to CurseForge if: env.HAS_CURSEFORGE == 'true' && env.CURSEFORGE_ID != '' @@ -84,8 +86,9 @@ jobs: curseforge-id: ${{ env.CURSEFORGE_ID }} version: ${{ env.TAG }} files: | - build/libs/!(*-@(sources|javadoc)).jar - build/libs/*-@(sources|javadoc).jar + build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}.jar + build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}-sources.jar + build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}-javadoc.jar loaders: | neoforge environment: client | server diff --git a/build.gradle b/build.gradle index 9c2641c..9a26e6c 100644 --- a/build.gradle +++ b/build.gradle @@ -163,6 +163,9 @@ configurations.named('runtimeTestCompileOnly') { extendsFrom configurations.compileOnly } +// Optional playable examples, packaged separately from MMDLib. +apply from: 'scripts/companion.gradle' + // Javadoc for extenders and maintainers: `./gradlew javadoc` (output in build/docs/javadoc). // Records and fluent builders are described in prose, so per-parameter tags are not required. tasks.named('javadoc', Javadoc) { diff --git a/docs/block-entities.md b/docs/block-entities.md index b55beb9..0e6357f 100644 --- a/docs/block-entities.md +++ b/docs/block-entities.md @@ -95,7 +95,8 @@ prepared handle. MMDLib does not create or attach that register: ```java // During construction, before registry events: -var types = DeferredRegister.>create(Registries.BLOCK_ENTITY_TYPE, MODID); +var types = DeferredRegister.>create( + net.minecraft.core.registries.Registries.BLOCK_ENTITY_TYPE, MODID); types.register(modBus); // After definitions have been queued, still before registry events: registrar.blockEntityHandles().values().forEach(handle -> handle.register(types)); @@ -126,8 +127,138 @@ public CrusherBlock create(BlockContext context) { ``` The block delegates `newBlockEntity` and `getTicker` to `binding.prepared()`, as -`MaterialEntityBlock` does, and reads `binding.type()` only after registration (for -example when a block entity is created), never in its constructor. +`MaterialEntityBlock` does. It also overrides `onRemove` as `MaterialEntityBlock` does: without +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. + +## 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. + +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 +contents against its saved cycle: + +```java +final FeatureHost features = new FeatureHost(this::setChanged).add(processor); + +// saveAdditional +tag.put("features", features.save(registries.createSerializationContext(NbtOps.INSTANCE))); +// loadAdditional, after the storages +features.load(registries.createSerializationContext(NbtOps.INSTANCE), tag.get("features")); +``` + +**Running recipes.** A `RecipeProcessor` is built from a feature key, a machine recipe type and a +`MachineInventory`, and added to the host; the block entity's ticker calls `features.tick(level)`: + +```java +final RecipeProcessor processor = new RecipeProcessor("work", MyRecipes.CRUSHING, inventory); +final FeatureHost features = new FeatureHost(this::setChanged).add(processor); + +// The factory's server-side ticker: +BlockEntityTicker ticker = + (level, pos, state, entity) -> entity.features.tick(level); +``` + +`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 +screen; `time()` is 0 when idle or paused. + +On `/reload` a running cycle finishes as planned, and the next cycle uses the new recipes. + +Failed recipe searches retry every 20 ticks while inventory revisions stay unchanged. Built-in +item/fluid groups report changes to contents, rules and reservations, so new inputs or freed output +space trigger a search on the next tick. Custom groups can implement `SlotGroup.revision()` for the +same response; the periodic retry also catches recipe reloads and changes in untracked groups. +Running cycles and consecutive successful cycles are not delayed. + +**Interrupting.** `interrupt()` stops the processor from drawing energy and starts nothing new +until `resume()`; `paused()` says whether it is held. It works without power and whatever the +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 +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 +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. + +A machine with one slot for both input and output must use one key for both. For a different-item +product, the cycle must also consume the entire stack: a recipe turning 1 iron into 1 gold cannot +start with 2 iron in the slot, because the leftover iron blocks the gold (`NO_ROOM`). A slot limit +of 1 makes that recipe usable without asking players to remove surplus iron. A product that stacks +with the remaining input can share the slot, subject to its limit. + +**Clearing.** When replacing a block, `/setblock` and `/fill` normally call `Clearable.tryClear` on +its block entity first, which is how a chest's contents vanish instead of dropping. A +block entity with `DropsContents` that should do the same also implements `Clearable`, and +empties its storages (and their plans) in `clearContent()`. Without it, the command drops the +contents. Both commands' `destroy` mode destroys the old block before clearing, so its contents +still drop even when the block entity implements `Clearable`. + +Call `RecipeProcessor.reset()` on each lane before clearing storage: it releases that lane's plans +and clears progress, the remembered recipe and pause state. Fresh inputs can then start new work. +Use `interrupt()` for a player-requested pause that should keep its place, not for clearing a machine. + +**Dropping contents.** A block entity of a `MaterialEntityBlock` (which includes `mmdlib:machine`) +that implements `DropsContents` has `dropContents(level, pos)` called on the server when its block +is broken or replaced by another block, but not when only its state changes. Usually it is +`items.dropAll(level, pos)`. Reserved items are still in their slots, so nothing else needs to be +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 +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`: + +```java +final FeatureHost features = + new FeatureHost(this::setChanged, () -> FeatureSync.send(this)).add(processor); + +@Override +public CompoundTag getUpdateTag(HolderLookup.Provider registries) { + return FeatureSync.updateTag(features, registries); +} + +@Override +public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); +} + +@Override +protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.loadAdditional(tag, registries); + if (FeatureSync.read(features, tag, registries)) return; + // the usual loading +} +``` + +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. + +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. +`restoreOnClient(state)` takes it on, by default as `restore`. `RecipeProcessor` overrides the +second to copy only its status, pause flag, progress and time, because a client has no storage +contents to check a cycle against, and never reserves anything. + +On the client, `processor.status()` and `processor.time()` are current. `processor.progress()` is +as of the last status change, so a progress bar reads it from a screen getter instead; screens keep +their own getter sync (see [GUI screens](gui-screens.md#wiring-a-machine)). While paused, `time()` +is 0, so a bar must not divide by it without checking. + +`FeatureSync.read` goes by the tag's key, not the side: a `/data merge` that includes the key +runs `restoreOnClient` on the server. ## Deferred registration and block hooks diff --git a/docs/capabilities.md b/docs/capabilities.md index 0e78252..1fe28fa 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -6,8 +6,8 @@ directly, and automation gets per-face views of it. | Class | Wraps | Main points | |---|---|---| -| `ItemStorage` | `ItemStackHandler` | Per-slot `Role`, filter, limit; `insertResult` for products; `dropAll` | -| `FluidStorage` | several `FluidTank`s as one `IFluidHandler` | Per-tank `Role`, capacity, filter; `tank(i)` for the machine | +| `ItemStorage` | `ItemStackHandler` | Per-slot `Role`, filter, limit; `insertResult` for products; `dropAll`; reservations | +| `FluidStorage` | several `FluidTank`s as one `IFluidHandler` | Per-tank `Role`, capacity, filter; `tank(i)` for the machine; reservations | | `EnergyBuffer` | `IEnergyStorage` | `long` internally; `int` values clamped for other mods; transfer rates | | `SideConfig` | | Per-face `IoMode`, stored relative to the machine's front; codec | | `MachineCapabilities` | `RegisterCapabilitiesEvent` | One-line registration with per-side modes | @@ -37,6 +37,37 @@ directly, and automation gets per-face views of it. machine facing up or down, `TOP` is the north side. `RelativeFace.facing(state)` reads the `facing` or `horizontal_facing` property, and defaults to north when the state has neither. +## Reservations + +`ItemStorage` and the client menu handler share `PlannedItemHandler` for insertion, extraction and +capacity rules. Item and fluid storages use `ReservationPlans` for plan merging, release and +transaction rollback. Direct content setters remain available to the owning machine and save loading. + +Custom menu handlers and wrappers can implement `ReservedSlots`, including `plan(slot)`, to publish +the same snapshots as `ItemStorage`. During a click, menu slots expose only unreserved items to +vanilla; outside the interaction, rendering and synchronization still show the full stack. A number +key or offhand swap can therefore move the free portion without moving reserved items. + +A machine that runs a recipe over many ticks can reserve what the recipe needs. `ItemStorage` +keeps one `SlotPlan` per slot and `FluidStorage` one `TankPlan` per tank: how much the machine will +take from it, and what it will add. `RecipeProcessor` makes and releases the plans itself; a mod +that runs its own cycles uses `reserve(slot, plan)`, `release(slot, plan)` and `plan(slot)` +(`tank` for fluids). A slot or tank with no plan behaves exactly as without this feature. + +What a plan does to the calls on the storage: + +| Call | With a plan | +|---|---| +| `extractItem`, `drain` | Never goes below the reserved amount. 50 iron with 1 reserved gives up 49 | +| `insertItem`, `fill` | Accepted only if the slot still ends up as one legal stack within its limit after the machine takes and adds what it planned, and the stack in the slot now can hold it | +| `insertResult` | Obeys the plan like `insertItem`; it skips roles and filters, not reservations | +| `tank(i)` | Its `fill` and `drain` obey the tank's plan | +| `isItemValid` | Answers for the planned end state, so a hopper is not offered what would break the plan | +| `setStackInSlot`, `setFluid` | Still bypass the plan. They stay available for undoing a transaction and loading a save, and they are the one way to break a running cycle | + +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. + ## Example ```java diff --git a/docs/gauge-textures.md b/docs/gauge-textures.md index f439048..86f03f3 100644 --- a/docs/gauge-textures.md +++ b/docs/gauge-textures.md @@ -5,6 +5,11 @@ Textures for the planned GUI fill gauge, styled as a glass graduated cylinder. T (standard library only; run `python3 scripts/generate_gauge_textures.py [thickness]`, default 18). `gui.client.GaugeRenderer` draws them; this page describes its texture contract. +These textures supply `` and the matching slim background of ``. The energy +renderer draws thin red charge bars with a soft reflection, without a terminal or graduations. +Bars are at most four pixels thick, separated by one-pixel gaps. +Both empty interiors use neutral dark grey `#555555`. + ## Files All textures are drawn for a **vertical** gauge 18 px thick. Horizontal gauges use the same @@ -12,11 +17,10 @@ files rotated 90° clockwise (see below). | File | Size | Contents | |---|---|---| -| `body_top.png` | 18×5 | Top cap of the tube: outline and dark glass interior, rounded corners | +| `body_top.png` | 18×5 | Top cap of the tube: thin outline and medium grey interior, rounded corners | | `body_middle.png` | 18×1 | One row of the tube, repeated to fill the length | | `body_bottom.png` | 18×5 | Bottom cap | -| `glass_top.png`, `glass_middle.png`, `glass_bottom.png` | as above | Translucent glass overlay: soft highlight in columns 2–3, faint shade in column 16 | -| `digits.png` | 39×5 | White 3×5 digits `0`–`9`; digit *n* starts at x = 4*n* | +| `glass_top.png`, `glass_middle.png`, `glass_bottom.png` | as above | Cylindrical lighting: dark curved sides, a broad lit face, and an off-centre vertical reflection | Pixels outside the rounded shape are fully transparent, so the GUI background shows through the corners. @@ -33,13 +37,15 @@ For a gauge of length *L* (≥ 10) at (*x*, *y*): 3. **Glass**: flush preceding buffered GUI draws, enable source-alpha blending, then draw the three `glass_*` pieces in the same arrangement as the body. The renderer restores the caller's blend enable/function, depth-test state and shader color after drawing the gauge. + The overlay is clipped to the filled portion, leaving the empty interior flat medium grey. + Fluid textures remain visible beneath it; the cylinder effect comes from lighting, not a new + outline. The energy gauge uses only the body textures, without this glass overlay. 4. **Graduations**, drawn in code: colour `#EEF3F6` at 85% opacity, with a 35% black shadow pixel directly below each mark pixel. From the left inside edge (x = 1): - every 5%: 2 px; every 10%: 3 px; 25/50/75: 7 px (40% of the 16 px inside width) - drop the 5% marks when they would be under 3 px apart, then the 10% marks -5. **Numbers** at 25/50/75, from `digits.png`, at 90% opacity with the same shadow, placed one - pixel right of the long mark and vertically centred on it. Hide them when the 25% marks are - under 8 px apart. + +The scale shows tick marks only, without numeric labels. Exact amounts remain in the tooltip. A mark at fraction *p* sits on row `1 + inner - round(inner * p)`, where `inner = L - 2`. @@ -60,8 +66,7 @@ Every `body_middle` row's interior is columns 1–16. Draw everything rotated 90° clockwise: the bottom cap becomes the left cap, the top cap the right cap, the highlight lands on top, and contents fill left to right. Marks hang down from -the top edge. Two things stay upright: the fluid sprite (tile it unrotated) and the numbers -(placed below their long marks, centred on them). Skip the meniscus. +the top edge. The fluid sprite stays upright (tile it unrotated). Skip the meniscus. An interactive mockup of the design was used to agree it; these files reproduce its pixels. diff --git a/docs/gui-screens.md b/docs/gui-screens.md index e152a6f..8e27ab3 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -62,6 +62,23 @@ contents, and asks the client block entity's `screenItems()` what each slot acce holds, so a click is predicted the way the server will answer it. It's only asked about rules, so its contents don't need to be synced. +Return the `ItemStorage` itself. A wrapper around it, or another `ReservedSlots` implementation, +loses the guards below and the client's copy of the plans. + +When the machine has reserved items in a slot (see [capabilities](capabilities.md#reservations)), +the menu keeps them from being taken or replaced: + +- Left and right click, Q and Ctrl+Q, and shift-click take only what is free. +- Clicking the slot with a different item is refused. +- A number key is refused on a slot with a plan, because vanilla would move the whole stack. +- Double-clicking a slot with a plan doesn't collect from elsewhere. Double-clicking another slot + still takes the planned slot's free part. + +The client's menu works out the same answer from the slot's plan, which arrives with the screen's +values, so clicks don't flicker. Bars and numbers still come from the getters named in the XML; a +feature's own client sync (see [block entities](block-entities.md#machine-features)) doesn't +replace them. + `screenDestinations` controls shift-click routing into the machine; its default tries all storage indexes, subject to their filters. `screenRecipeSlots` explicitly names recipe inputs in recipe order; its default disables JEI transfer. Both methods must work on the client as well as the @@ -249,6 +266,21 @@ bottom automatically. Write `` only to place it somewhere els screen file without one. The tooltip says what the button does or shows (for example "Side configuration: choose which faces accept and output each slot group, tank and energy"), because icon buttons alone were found unclear. +- **Stateful icon buttons:** `icons` is a whitespace-separated list of sprite IDs selected by a + boolean (`false` = 0, `true` = 1) or whole-number `value`. Optional `value-labels` supplies + translated names for `{value}` in the tooltip. When both lists are present their lengths must + match. Missing or out-of-range values display the first state. These buttons keep the standard + 20×20 icon-button size by default. Set `flat="true"` for a 14×14 control with a 12×12 + icon, a uniform one-pixel dark border and a flat grey background that lightens on hover. + Flat buttons do not take text captions. An `hbox` with a + leading `spacer` places them against its right edge. + + ```xml +