Skip to content

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

Open
rubensworks wants to merge 1 commit 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 1 commit into
master-1.21-ltsfrom
feature/instant-storage-terminal-interaction

Conversation

@rubensworks

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.

The server stays the only source of truth. 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. Slots the client changed but the server did not are the one case per-slot sync cannot fix — the server sees no change, so it sends nothing — so handleStorageSlotClick now ends with broadcastFullState().

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.

Also: the search query was parsed and its regex recompiled once per shown ingredient on every view rebuild. It is now compiled once per rebuild (IngredientQueryMatchers), 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 it walks every position of every channel on the click's tick, which is a server-side cost on exactly the large networks that have had performance trouble before.

Testing

./gradlew build and ./gradlew runGameTestServer pass (45 game tests, 20 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.
  • GameTestTerminalStorageIngredientPredictions — the view overlay: subtraction, an emptied instance disappearing, additions, 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 of 192 diamonds, 128 iron and 32 logs, 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 192 → 128 and the slot went empty → 64. After the server answered, both stayed exactly there (no double subtraction, no revert), and the chest server-side had lost exactly 64.
  • Shift-clicking a stack back into storage: same tick, the slot emptied and the grid went 128 → 192; server state matched.
  • A press alone (no release) selects a slot, and the following release does not act on it again.
  • A prediction the server was never told about reverted by itself after its expiry, and a following real click's full-state broadcast cleared the phantom item it had left in the inventory, while keeping the legitimate one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N51XZVzjfA7j3EUKbBaCZW


Generated by Claude Code

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
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