Releases: ValentinTarnovsky/SnLib
Release list
SnLib v1.28.0
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'sclick-actions, so one cell is a button and a drop target at once.player-inventory: locked | open(menu level, defaultlocked) decides whether the viewer may use their own inventory at all. Underopentheir 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:
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.- 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.
OfferRoutingTestasserts exactly that for everyClickTypein every zone and for everyInventoryAction, not as a sample. SnChat'sSnapshotGuianti-dupe guarantee and SnCrates'RewardListViewHIGHEST bottom-click handler both rest on that property and both keep working unchanged. - New public surface only:
PlayerInventoryPolicy, theItemOfferrecord and itsKindenum,GuiItemDef.input(),GuiDef.playerInventory(), andGuiSession.onOffer/handleOffer/isInputSlot/playerInventory. No removals, no changed signatures. - japicmp additive-only gate passes against the
com.sn:snlib:1.0.0baseline. SnApi.LEVEL18 -> 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
SnApijavadoc, which is the source of truth. docs/consumer-pom-template.xmlmoves off its stale1.21.1snlib pin.
SnLib v1.27.0
A continuation a caller can chain onto
Nothing on SnFuture returned a new stage. thenSync, exceptionally and orDisablePlugin all end in return this, so a method ending in
return write(...).thenSync(publish);whose caller writes callee().thenSync(next) did not build a chain. It registered two dependents on ONE CompletableFuture. Sibling order is unspecified and OpenJDK pops dependents last-registered-first, so next ran before publish, and read exactly the state the publish was about to install. Hopping to the main thread did not rescue it: both tasks were then queued in that same inverted order.
"B after A's continuation" was inexpressible on this surface, so no amount of documentation could fix it.
SnFuture.chainSync(Consumer<T>)
Runs the consumer on the main thread like thenSync, but returns a new future settled from inside that same task, after the consumer returned. Whatever the caller registers on it is a successor.
The fix belongs in the producer: swap its one thenSync and every existing caller becomes correct without being touched.
// before: the caller's step is a sibling of the publish
return writeState(KEY, date).thenSync(ignored -> lastResetDate = date);
// after: the caller's step is a successor
return writeState(KEY, date).chainSync(ignored -> lastResetDate = date);Three deliberate divergences from thenSync
They all follow from the caller's handler now hanging off the derived future instead of the source.
- A failure is propagated rather than swallowed into one WARN, so terminate the chain with
exceptionallyororDisablePlugin. A chained failure nobody consumes is a failure nobody sees. - A consumer that throws fails the chain instead of only reaching Bukkit's task reporter, because a link that never settles is a permanent hang for whoever waits above it.
- A skipped hop (plugin already disabled, context tearing down, or the disable race) completes the returned future normally with the value, so a teardown never turns into a hang. A normal completion therefore does not prove the consumer ran, and the two are deliberately indistinguishable.
Never wait on a chained future
While the plugin is running, only a main-thread task can complete it, so it carries the wrapMainCompleted marking and a main-thread join()/joinWithin throws instead of deadlocking the server. The marking is not applied when the plugin is already disabled or the context is already tearing down at the moment chainSync is called, because from then on the hop can never happen and the future settles on the completing thread; marking it anyway would make the guard fire during a teardown flush on a wait that would have returned.
A producer whose future a teardown flush joins must keep using thenSync. Joins belong on the future the database module returned.
One window does not settle and is documented rather than closed: a hop that was queued and then cancelled by a disable leaves the future pending. That is precisely what thenSync already did in the same window.
Also in this release
SnPapi.applyOnMain returned SnFuture.wrap for a future only its own main-thread task can complete. A future produced off the main thread and then waited on from it was a silent, permanent server deadlock with no log line. Both overloads now return wrapMainCompleted on the off-main branch, which turns the hang into an immediate throw. Shipped as its own commit, because it is a behaviour change rather than an addition.
Compatibility
Strictly additive. API level 17 -> 18. Every existing method is byte-for-byte unchanged, japicmp passes against the baseline, and every one of the 111 thenSync call sites across the consumer plugins keeps its exact current behaviour. An old consumer jar runs on 1.27.0 unchanged.
A consumer compiled against 1.27.0 inlines API level 18 into its bytecode and will refuse to enable against an older installed SnLib.jar, so update the library on the server before the plugin that needs it.
508 tests, 8 of them new, covering every branch of the sequencing core including the residual window above.
SnLib v1.26.0
The owner picks how a number reads.
A plugin decided at its call site whether a balance rendered 1500000, 1,500,000 or 1.5M. The server owner could not change it without the plugin being rebuilt, and across a fleet that meant three formatters with three different suffix ladders, plus plugins that formatted nothing at all and left raw digits sitting in chat.
SnText.applyLocals now understands a trailing format hint on a local token. The choice moves to the language file the owner already edits.
| Hint | 1500000 becomes |
|---|---|
{balance:short} |
1.5M |
{balance:grouped} |
1,500,000 |
{balance:raw} |
1500000 |
# Before
balance-msg: "&aYou have &f{balance} &acoins" # You have 1500000 coins
# After
balance-msg: "&aYou have &f{balance:short} &acoins" # You have 1.5M coinsIt works in messages, item names, item lore and menu titles, and it needs no change to the consumer plugin: update SnLib.jar, restart, and every plugin already installed honours it.
Inert unless it is asked for. Five rules keep it from touching text that never opted in:
- the full token resolves FIRST, so a key that itself contains a colon is never reinterpreted as a hint;
- only
raw,shortandgroupedcount. A Discord timestamp like<t:1700000000:R>, or a PAPI token that takes an argument, passes through verbatim; - an unknown key keeps the whole token, hint included, exactly as an unhinted unknown token already did;
- a non-numeric value comes back UNCHANGED rather than blanked, so
{player:short}still renders the name; - a non-finite result is left alone, since
parseFormattedaccepts1e400andBigDecimal.valueOfwould throw on it.
short and grouped round HALF_UP to two decimals; raw never rounds, because exactness is the only reason to reach for it.
One caveat worth knowing: the value is re-parsed from its rendered form, which is precisely what lets an unmodified plugin honour the hint. So a caller that already abbreviated hands over 1.23M, and :raw answers 1230000 rather than the exact figure. Only a caller that passes unformatted digits can be re-rendered losslessly.
No public API added, so SnApi.LEVEL stays 17 and no consumer needs recompiling to benefit. 500 tests, 12 of them new.
SnLib v1.25.0
Placeholders that need no player.
Not every placeholder describes a player. A leaderboard row, a server-wide counter, an event countdown - that is global data that merely happens to be exposed through PlaceholderAPI's per-player surface. And the callers that ask for it routinely supply no player at all: a global hologram, a Discord bridge, a console task, /papi parse --null.
BuiltExpansion.onRequest rejected a null requester before it had even looked at which resolver was being asked for, so a token that needed no player was dropped along with the ones that did. The consumer could not opt out: sn.papi().expansion(...) is the only registration path, and taking the raw PlaceholderExpansion back would take back registration, persistence and unregistration with it.
ExpansionBuilder.global(String param, Supplier<String>)andglobalPrefixed(String prefix, Function<String, String>)- bind a resolver that takes NOOfflinePlayer, so it cannot dereference one and it answers a null requester like any other. Same keyspace and precedence asplaceholder/prefixed; the later declaration for one key wins outright.onRequestnow locates the resolver FIRST and rejects the null requester SECOND. Everything bound throughplaceholder/prefixedkeeps the short-circuit exactly as before, so a resolver written against the non-null contract its binder documents never starts seeing nulls.LeaderboardCache.exposePlaceholders(id)binds itstop_tokens global, so every board exposed through it renders in a global hologram without the consumer changing a line.pos_<id>asks about the requester by definition and stays player-bound.
sn.papi().expansion("shop")
.placeholder("balance", player -> money(player)) // needs a player
.global("open_shops", () -> String.valueOf(registry.openCount()))
.globalPrefixed("top_", position -> topSellerAt(position))
.register();Strictly additive: two new public methods, zero public signatures changed, and the only behaviour change on an existing path is exposePlaceholders, which answers strictly more than it did. SnApi.LEVEL 16 -> 17.
SnLib v1.24.1
Fixes
SnYml.flush() no longer blocks on a write it can land itself.
flush() joined the scheduled async write with a 10-second Future.get on the calling thread. On
a live 1.21.8 Paper server, a consumer that paired save(); flush(); on the primary thread saw
that join expire its full budget on every single call - ten seconds of frozen server per admin
command, with a watchdog thread dump each time. The data still landed, because flush() fell
through to an inline write once the timeout expired; only the clock was lost.
flush() now takes the staged snapshot over and writes it itself. The scheduled drain then finds
nothing staged and exits.
Two waits are involved and only one is bounded, which is the design:
- Waiting on somebody else's in-flight write is a courtesy, capped at 10s.
ioLockbecame a
ReentrantLockfor this - an intrinsic monitor cannot be acquired with a timeout, and a
version that usedsynchronizedwould hang teardown forever on a wedged disk. - Writing the snapshot
save()staged is not a courtesy. It always happens, blocking
uninterruptibly if the disk is wedged, exactly as the previous code did after its timeout.
drainPendingWrites() now holds ioLock across the take as well as the write, so a concurrent
flush() cannot return while the drain is holding the only copy of a snapshot.
Compatibility
No public surface changes. SnApi.LEVEL is unchanged and japicmp reports no incompatibility, so
consumers pinned at any earlier 1.x need no recompile.
Consumers need no change. save() alone was always the complete persist path at runtime;
flush() is a teardown primitive the context already calls for every mounted file. Pairing
save(); flush(); in a command or listener was never necessary and is what triggered the freeze.
SnLib v1.24.0
A teardown flush that can be bounded.
Bukkit clears the plugin's enabled flag BEFORE onDisable runs, so thenSync's is-enabled guard drops every success continuation from the first line of onInnerDisable onward. Blocking was the only way left to observe a teardown write, and join() had no timeout - a consumer flushing player state had to choose between freezing the server stop on one unreachable database and dropping the write ordering the flush exists for.
SnFuture.joinWithin(Duration)-join()under a budget.true= settled in time (the value is then one non-blockingjoin()away),false= the budget ran out with the work still running (not cancelled), and a throw = it FAILED, with the sameCompletionException/CancellationExceptionjoin()already throws. Same main-thread rules asjoin(): silent in teardown and bootstrap, warned elsewhere, and a main-completed future still refuses to be waited on from the main thread.DbConfig.connectTimeoutSeconds()/socketTimeoutSeconds()- a budget above the future is only half a bound.SnDbset no HikariconnectionTimeoutand leftsocketTimeoutat the driver's unlimited default, so onegetConnection()against a black-holed host outlasted any consumer-side timeout. Two optional keys in the samedatabasesection, safe defaults, existing configs unchanged:
database:
connect-timeout-seconds: 10 # default 10, clamp 1..3600 - Hikari connectionTimeout (both backends) + MySQL connectTimeout
socket-timeout-seconds: 30 # default 30, clamp 0..3600, 0 = unlimited - MySQL socketTimeoutStrictly additive: three new public methods, zero signatures changed, zero behaviour changed on any existing path. japicmp gate passes. SnApi.LEVEL 15 -> 16.
SnLib v1.23.0
Placeholders on an event thread.
SnPapi.applyHere(viewer, text)resolves PAPI on the calling thread, the documented escape hatch for text assembled inside an event (above allAsyncChatEvent) that has no later main-thread point to resolve at. Fail-open;applyandapplyOnMainremain the answer everywhere else.SnLang.get(key, viewer, Ph...)/getList(key, viewer, Ph...)render a fragment FOR a viewer, for text spliced into something else rather than sent.
Strictly additive, japicmp gate passes. SnApi.LEVEL 14 -> 15.
SnLib v1.21.1
Changes
Refinements to the 1.21.0 plugin-supplied stack surface. No API change - SnApi.LEVEL stays 13 and nothing public was added, removed or altered.
The pass-through is now structural
A template that declares neither display-name nor lore adds nothing to a supplied stack: no appended empty line, no cleared lore, no name written, no normalising pass. This already held in 1.21.0, but it held by convention - the item meta was still fetched. The emptiness test now runs before the meta is fetched at all, so a bare template cannot write to the stack even by accident. The result is the plain clone, equal to the input field for field.
Every shape of "undeclared" reaches that same guarantee: an absent key, display-name: "", and lore: []. The distinction it rests on is unchanged and now tested head-on:
raw-item: # adds nothing at all
click-actions:
- "[player] kit claim {kit}"
spaced-item:
lore:
- "" # a list holding an empty string DOES append a blank line
- "&7Click to claim"Scope, stated honestly: this is the contract of the overlay. The stack that reaches the inventory still carries the snlib_gui_item anti-theft marker - one PDC key, written after the overlay runs, as on every rendered GUI stack since 1.0.0.
The allocation cost is documented instead of implied
docs/SNLIB-DOCS.md section 12 now itemizes it, so a consumer can size the surface from facts:
- Per bind: one
Ph[]clone, oneBindingrecord, oneItemStackclone. - Per render: one
ItemStackclone always, plus oneComponentper name and lore line the template actually declares - and nothing else for a bare template. - Strictly cheaper than the
SnItem.fromConfigrender it stands in for (~20 YAML reads plus a full meta write). - Sizing: built for opens, refreshes, page changes and
update-interval:ticks. A per-frame full-strip repaint at animation rates belongs on a raw Bukkit inventory.
484 tests across 22 suites green; additive-only japicmp gate clean.
Installation
Download the JAR below and place it in your plugins folder, replacing the previous SnLib.jar. Updating SnLib requires a server restart; never hot-reload it.
SnLib v1.21.0
Changes
Plugin-supplied stacks in menus. All three GUI bind surfaces now accept a ready-made ItemStack, so a menu can display contents the plugin did not author - crate rewards, kit contents, shop stock, lootbox previews - whose enchantments, custom model data, head texture and custom name no YAML item definition can re-express.
The split is: the stack supplies the appearance, the template keeps supplying the behaviour.
s.bind(22, gui.template("reward"), reward.icon(), Ph.of("chance", 25)); // manual bind
s.bindPaged("kit-item", kit.contents(), (i, ph) -> ph.stack(i)); // paged entry
s.bindEach("stock", offers, (o, e) -> e.template("offer").stack(o.item())); // region cellNew public methods:
GuiSession.bind(int slot, GuiTemplate template, ItemStack stack, Ph... phs)PhCollector.stack(ItemStack stack)GuiEntry.stack(ItemStack stack)
Overlay rules, identical on all three: a non-empty display-name replaces the stack's name, declared lore lines are appended after the stack's own lore, both resolved through the normal pipeline (viewer PAPI, local placeholders, colour, rgb). A template that declares neither leaves the stack visually untouched; nothing else of the template is applied.
Unchanged: view-requirements, the per-click matrix, click and deny actions, render precedence, itemAt, handleClick, the strict-clicks gate, bind lifetime across page changes, refreshes and inventory recreation, and the snlib_gui_item anti-theft stamp. A null stack is exactly the previous behaviour, so the surface is strictly additive: every consumer compiled against 1.20.3 keeps compiling and behaving identically.
SnApi.LEVEL 12 -> 13. The additive-only japicmp gate records three new methods and no removal.
Installation
Download the JAR below and place it in your plugins folder, replacing the previous SnLib.jar. Updating SnLib requires a server restart; never hot-reload it.
SnLib v1.20.3
Fix
A consumer that refuses to enable - an invalid license, an absent requirement - disables
itself and then leaves onInnerEnable(). Until now the throwing form of that abort was
reported as a bug: right after the gate's own clean one-line refusal, the library printed
SEVERE: onInnerEnable failed plus the full stack trace.
SnPlugin.onEnable now branches its Throwable catch on isEnabled():
- already disabled - the consumer took the decision itself and threw only to cut the
enable short. One line,Enable aborted: <reason>; the throwable stays available at
FINE; no seconddisablePlugin. - still enabled - nobody disabled anything, so the failure is unexpected. Unchanged:
SEVEREwith the full stack trace, then the library disables the plugin.
The same check now guards applyLang() on a normal return, so aborting with a plain
return after disabling is supported by construction instead of by the accident of the
command roots being empty after the teardown.
Impact
No consumer needs a recompile. Replacing SnLib.jar is enough - every already-released
licensed plugin gets the clean log as it stands.
SnApi.LEVEL stays at 12: no public surface was added, and japicmp reports no change
to SnPlugin. A public abort primitive would have meant level 13 and a rebuild of every
licensed consumer for the same visible result.