Skip to content

Show storage terminal interactions before the server confirms them - #217

Open
rubensworks wants to merge 3 commits into
master-1.21-ltsfrom
feature/instant-storage-terminal-interaction
Open

Show storage terminal interactions before the server confirms them#217
rubensworks wants to merge 3 commits into
master-1.21-ltsfrom
feature/instant-storage-terminal-interaction

Conversation

@rubensworks

@rubensworks rubensworks commented Sep 4, 2026

Copy link
Copy Markdown
Member

Player report: "just annoying grabbing from storage isnt instant".

Why it happens

Every terminal interaction is a custom packet with no client-side prediction, so nothing at all happens until the server answers. Three delays stack up on a single grab:

  1. The click waits for the mouse button to come up. ContainerScreenTerminalStorage.mouseClicked only set a flag; handleClick ran in mouseReleased. That is the whole button hold, typically 50-150ms, before the packet is even sent. Shift-clicking into storage already went through vanilla's quickMoveStack on press, so the two directions were not even consistent.
  2. The item lands after a round trip. The server applies the move and syncs the slot, but the client shows nothing in the meantime. Vanilla containers feel instant because they apply the click locally and let the server's SetSlot correct them.
  3. The shown quantity lags further. Extraction only schedules a forced observation in the ingredient network. The change then has to pass the observer job (async, one at a time), a hop back to the server thread, a packet, and a client tick, so the count in the grid trails the grab by several ticks, more on a large network.

What this changes

The client now simulates a click as soon as it sends it.

The container change is simulated by running the server's own movement logic. predictInsertIntoContainer, predictInsertMaxIntoContainer and predictExtractMaxFromContainerSlot call the real insertIntoContainer / insertMaxIntoContainer / extractMaxFromContainerSlot against the client container and a storage holding what the client believes is available (IngredientComponentStorageCollectionWrapper). No movement rules are duplicated, so what is predicted is what the server will do, for every ingredient type. The prediction passes no player, so it never picks a slot's contents up into the cursor; where the server would do that, the prediction simply moves nothing and the player waits as before.

The shown quantities get the predicted change layered on top of the server-sent state, in TerminalStorageIngredientPredictions, and never merged into it. This matters: the server sends diffs, so a prediction merged into the client's mirror would be applied twice and the mirror would drift for as long as the GUI stays open. A prediction is consumed when the server sends the change it expected (by quantity, so a second click stays shown while the first is confirmed), and expires on its own when the server never sends it.

Only what leaves the storage is predicted for the grid. Whether the storage accepts an instance depends on position filters, on the free space per position, and on the network's transfer rate, none of which the client knows, so predicting an arrival could show ingredients the network does not have until the prediction expired. The inventory slot an instance leaves is still predicted, and is corrected by the server as below.

Reconciliation is vanilla's. The click packet carries the container slots that the prediction changed; the server marks those as what the client believes (setRemoteSlotNoCopy) before broadcasting its own changes, so exactly the slots they disagree about are corrected. This is what ServerGamePacketListenerImpl.handleContainerClick does for vanilla clicks, and what this terminal already relied on for quick-moves into storage, which travel through the vanilla click packet. Without it, a server that moves less than predicted into more than one slot silently leaves the untouched slots wrong on the client.

Clicks that cannot start a drag are handled on press, gated by a new ITerminalStorageTabClient#isClickHandledOnPress (default false, so third-party tabs are unaffected). This is exactly vanilla's rule: act on press while the cursor is empty, defer to release while it is not, since that press may be the start of a drag.

Prediction can be turned off with the new client-side guiStoragePredictInteractions config, and a prediction that throws is logged and skipped rather than swallowing the click.

Also: the search query was parsed and its regex recompiled once per shown ingredient on every view rebuild, and an empty search resolved every ingredient's display name. Both are now done once per rebuild (IngredientQueryMatchers, IngredientQueryLeaf), which matters more now that predictions rebuild the view more often.

Deliberately not included: forcing a synchronous network observation on each click. It would shorten the window a prediction is shown, but IngredientObserver.observe(true) can block the main server thread on the async observer barrier, and it would still leave a full round trip and do nothing for the inventory slot.

Testing

./gradlew build and ./gradlew runGameTestServer pass (44 game tests, 19 of them new):

  • GameTestTerminalStorageClickPredictions — the simulated movements: one stack per quick move, limited by what the storage holds, partial stacks filled, occupied slots left alone, extraction limits, and the changed slots that are reported to the server.
  • GameTestTerminalStorageIngredientPredictions — the view overlay: subtraction, an emptied instance disappearing, the wildcard channel, confirmation by a server change (full, partial and larger-than-predicted).
  • GameTestIngredientQueryMatchers — query matching, including invalid regexes matching nothing rather than throwing per ingredient.

Verified in a dev client (clientdevbridge-cli) on a real network, a cable with an item interface on a chest and a storage terminal part:

  • Grabbing 64 diamonds into an inventory slot: within the same client tick, with no server round trip, the grid went 128 → 64 and the slot went empty → 64. After the server answered, both stayed exactly there, and the chest server-side had lost exactly 64.
  • Shift-clicking a stack back into storage: same tick, the slot emptied; server state matched.
  • A press alone (no release) selects a slot, and the following release does not act on it again.
  • Mispredictions correct themselves: with the storage secretly holding 20 of an item while the client still showed 64, a quick move predicted 64 across two inventory slots, the server could only fill the first one, and the client ended up with the first slot corrected to 60 and the second one emptied again, matching the server exactly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N51XZVzjfA7j3EUKbBaCZW

rubensworks and others added 3 commits September 4, 2026 21:04
Every terminal interaction was a custom packet with no client-side
prediction, so nothing happened until the server answered: the grabbed
item appeared after a round trip, and the shown quantity only after the
ingredient network observer had picked up the change, a few ticks later.

The client now simulates a click as soon as it sends it:

* The container change is simulated by running the same movement logic
  that the server runs, against a storage that holds what the client
  believes is available. So what is predicted is what the server does.
* The shown quantities get the predicted change on top of the
  server-sent state, never merged into it, as the server sends diffs
  that would otherwise be applied twice.

The server stays the only source of truth. A prediction is dropped as
soon as the server sends the change it expected, and expires by itself
when the server never does. Slots that the client changed but the server
did not are corrected by sending the full container state after a click,
which the regular per-slot sync can not do.

Clicks that can not start a drag are also handled when the mouse button
goes down instead of when it is released, like vanilla containers do
when the cursor is empty. This drops the button hold time, which was
part of every interaction.

Also parse the search query once per view rebuild instead of once per
shown ingredient, as predictions rebuild the view more often.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N51XZVzjfA7j3EUKbBaCZW
Sending it after every click undid the predictions of any click the
player made in the meantime, until the server had caught up with those
as well, so clicking faster than the round trip made items flicker.

The server's own changes are already sent as usual, which confirms or
corrects the prediction. The only case that is not covered is a slot
that only the client changed, and that happens exactly when the server
moved nothing at all, so send the full state only then.

Also skip resolving every ingredient's name when the search is empty,
which is the state the terminal is in whenever the player is not
searching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N51XZVzjfA7j3EUKbBaCZW
The server only sends the slots that changed for it, so a slot that only
the client predicted was never corrected when the server moved less than
predicted into more than one slot: the server sent the slots it did fill
and stayed silent about the rest, leaving items in the client's inventory
that are not there. Sending the full state when the server moved nothing
did not cover that, as the server did move something.

The click now carries the slots that the prediction changed, and the
server marks those as what the client believes before it sends its
changes, so every slot they disagree about is corrected and no other slot
is touched. This is what vanilla does for its own container clicks, and
what the terminal already relied on for quick-moves into storage, which
go through the vanilla click packet.

Predicting the arrival of an instance in the storage is dropped: whether
the storage accepts it depends on position filters, on the free space per
position, and on the network's transfer rate, none of which the client
knows, so it could show ingredients that the network does not have until
the prediction expired. The slot it leaves is still predicted, and is
corrected by the server as above. What the player takes out of the
storage is unaffected, which is what this is all about.

A failed prediction no longer swallows the click either, as predicting
now runs before the click is sent, and it runs the ingredient component's
own movement logic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N51XZVzjfA7j3EUKbBaCZW
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant