Skip to content

SnLib v1.28.0

Latest

Choose a tag to compare

@ValentinTarnovsky ValentinTarnovsky released this 13 Aug 14:02

A menu that can be handed an item

The GUI module could show anything and receive nothing. Every click and drag over a library menu was cancelled, unconditionally - which is exactly what makes a declarative menu safe, and what made one shape of menu inexpressible: the one that asks the player for an item. A shop asking which item you are selling. A kit editor asking what goes in slot 4. A deposit cell.

Two plugins had already paid for that gap by dropping out of the module entirely: SnDisplayShops' owner menu and SnKits' KitItemsEditor are raw Bukkit inventories with hand-rolled listeners, layouts living outside guis/, and none of what the module gives you - no view requirements, no per-click matrix, no anti-theft marker, no tenant teardown.

1.28.0 closes it with two yml keys and one callback.


The declaration is config

# guis/editor.yml
title: "&8Kit editor"
player-inventory: open      # the viewer may use their own inventory

layout:
  - "fffffffff"
  - "ffffiffff"
  - "fffffffff"

items:
  slot:
    key: i
    input: true             # THIS cell receives an item
    material: LIGHT_GRAY_STAINED_GLASS_PANE
    display-name: "&eDrop an item here"
    click-actions:          # still fires when the cursor is EMPTY
      - "[message] &7Hold the item you want to place."
  • input: true (item level, also on templates) marks the cell as an INPUT SLOT. A viewer who clicks it holding a stack, or drags a stack onto it, hands that stack to the plugin. A click with an empty cursor is not an offer and still runs the cell's click-actions, so one cell is a button and a drop target at once.
  • player-inventory: locked | open (menu level, default locked) decides whether the viewer may use their own inventory at all. Under open their plain clicks, number keys, Q drops, F swaps and drags inside their own inventory work again - stack splitting used to fail silently - and a shift-click there becomes an offer too, because a shift-click aims INTO the menu and only the plugin can decide what that means.

The two are orthogonal and both default to the old behaviour. The pair is checked at parse: an input cell with a locked player inventory WARNs, because a viewer who can never pick a stack up can never offer one.

The Java side is one callback

GuiSession s = gui.session(player);
s.onOffer(offer -> {
    kit.setIcon(offer.stack());                       // a copy, with its real amount
    s.bind(offer.slot(), gui.template("filled"), offer.stack());
});
public record ItemOffer(Player viewer, Kind kind, int slot, int playerSlot,
                        ItemStack stack, ClickType click) {
    public enum Kind { CURSOR, DRAG, SHIFT_CLICK }
}
Kind slot() playerSlot() click()
CURSOR the input cell clicked -1 LEFT or RIGHT
DRAG the single input cell covered -1 RIGHT for a single-item drag, LEFT for an even spread
SHIFT_CLICK -1 the player-inventory slot the stack came from SHIFT_LEFT / SHIFT_RIGHT

click() is carried so the vanilla convention stays expressible: right deposits one, left deposits the stack. stack() always carries its real amount, which is what a deposit needs.

The guarantee: the item is READ, never consumed

Every event behind an offer is cancelled before the handler runs, and the offer carries a defensive clone. The cancel itself is what puts the stack back on the cursor or in the inventory. SnLib does not move it, shrink it, delete it, store it, or write it into the menu.

That line is deliberate. How much of a stack you accept, where it goes and what the cell then shows are consumer decisions, and a library that guessed them would own the money-shaped half of every deposit flow. playerSlot() is what lets the consumer write the remainder back itself:

s.onOffer(offer -> {
    if (offer.kind() != ItemOffer.Kind.SHIFT_CLICK) {
        return;
    }
    ItemStack offered = offer.stack();
    int accepted = vault.deposit(player, offered);
    if (accepted <= 0) {
        sn.lang().send(player, "vault.full");
        return;
    }
    ItemStack remainder = offered.getAmount() > accepted
            ? offered.asQuantity(offered.getAmount() - accepted)
            : null;
    player.getInventory().setItem(offer.playerSlot(), remainder);
    player.updateInventory();      // the ghost-stack resend, see below
    s.refreshMenu();
});

Always follow a write-back with updateInventory(). The click was cancelled, so the client is still drawing the stack it had before the event; when you then change that slot server-side, the client keeps painting a stale stack the player can appear to click on. One resend fixes it. It is documented on the record, on the developer page and on the admin page, because it is the single most common way to get this wrong.

The routing is a pure, exhaustively tested table

GuiClickListener is now a thin adapter over a new pure core, gui/internal/OfferRouting: zone + policy + action + click + three booleans in, one Decision out.

Situation Decision
Action is COLLECT_TO_CURSOR, any zone, any policy, input cell or not CANCEL_ONLY
TOP cell, input, cursor non-empty, click LEFT or RIGHT OFFER_CURSOR
TOP cell, anything else CANCEL_AND_CLICK
OUTSIDE the window, either policy CANCEL_ONLY
BOTTOM, policy LOCKED CANCEL_ONLY
BOTTOM, policy OPEN, shift over a non-empty stack OFFER_SHIFT
BOTTOM, policy OPEN, anything else (incl. a shift over an EMPTY slot) PASS_THROUGH
DRAG covering no menu cell, policy OPEN PASS_THROUGH
DRAG covering no menu cell, policy LOCKED CANCEL_ONLY
DRAG covering exactly ONE input cell, stack non-empty OFFER_DRAG
DRAG, anything else (2+ cells even if all input; one non-input cell; empty cursor) CANCEL_ONLY

Two invariants carry everything else, and both are asserted directly:

  1. COLLECT_TO_CURSOR (the double-click gather) is cancelled first, unconditionally, under both policies, input cell or not. It is the one action that pulls stacks out of the TOP inventory while the click that fires it lands on the bottom one.
  2. A click on a cell of the menu is always cancelled - as a plain click or as an offer, it makes no difference. No rendered stack can therefore reach the cursor, which is precisely what makes leaving the player's own inventory alone safe.

Deliberate divergences worth knowing: a shift-click over an EMPTY bottom slot passes through rather than being cancelled (vanilla does nothing with it, and cancelling a no-op only costs a client desync); a drag spread over several input cells is cancelled rather than split, because inventing a split would be SnLib deciding how much each cell gets; and offers never pass through strict-clicks, which filters ACTIONS - an offer is not one.

GuiProtectionListener is untouched. Nothing in the new paths stamps, writes or moves a stack anywhere.

Compatibility

  • Strictly additive. Every new yml key defaults to the pre-1.28.0 behaviour, and a menu that declares neither is byte-identical in what it cancels and what it dispatches. OfferRoutingTest asserts exactly that for every ClickType in every zone and for every InventoryAction, not as a sample. SnChat's SnapshotGui anti-dupe guarantee and SnCrates' RewardListView HIGHEST bottom-click handler both rest on that property and both keep working unchanged.
  • New public surface only: PlayerInventoryPolicy, the ItemOffer record and its Kind enum, GuiItemDef.input(), GuiDef.playerInventory(), and GuiSession.onOffer / handleOffer / isInputSlot / playerInventory. No removals, no changed signatures.
  • japicmp additive-only gate passes against the com.sn:snlib:1.0.0 baseline.
  • SnApi.LEVEL 18 -> 19. Consumers built against LEVEL 18 keep running against this jar unchanged; a consumer that uses the new API needs SnLib 1.28.0 or newer installed, and the handshake tells it so at enable time instead of failing later.
  • 532 tests green (508 before, 24 new).

Also in this release

  • The GitBook LEVEL history had drifted four releases behind (it stopped at 13 and claimed 13 was the current value). It is filled in through 19 from the SnApi javadoc, which is the source of truth.
  • docs/consumer-pom-template.xml moves off its stale 1.21.1 snlib pin.