39 releases, newest first.
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.
# 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.
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.
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.
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.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.
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.PlayerInventoryPolicy, the ItemOffer record and its Kind enum, GuiItemDef.input(), GuiDef.playerInventory(), and GuiSession.onOffer / handleOffer / isInputSlot / playerInventory. No removals, no changed signatures.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.SnApi javadoc, which is the source of truth.docs/consumer-pom-template.xml moves off its stale 1.21.1 snlib pin.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);thenSyncThey all follow from the caller's handler now hanging off the derived future instead of the source.
exceptionally or orDisablePlugin. A chained failure nobody consumes is a failure nobody sees.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.
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.
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.
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:
raw, short and grouped count. A Discord timestamp like <t:1700000000:R>, or a PAPI token that takes an argument, passes through verbatim;{player:short} still renders the name;parseFormatted accepts 1e400 and BigDecimal.valueOf would 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.
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>) and globalPrefixed(String prefix, Function<String, String>) - bind a resolver that takes NO OfflinePlayer, so it cannot dereference one and it answers a null requester like any other. Same keyspace and precedence as placeholder / prefixed; the later declaration for one key wins outright.onRequest now locates the resolver FIRST and rejects the null requester SECOND. Everything bound through placeholder / prefixed keeps 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 its top_ 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.
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:
ioLock became a
ReentrantLock for this - an intrinsic monitor cannot be acquired with a timeout, and a
version that used synchronized would hang teardown forever on a wedged disk.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.
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.
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-blocking join() away), false = the budget ran out with the work still running (not cancelled), and a throw = it FAILED, with the same CompletionException / CancellationException join() already throws. Same main-thread rules as join(): 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. SnDb set no Hikari connectionTimeout and left socketTimeout at the driver's unlimited default, so one getConnection() against a black-holed host outlasted any consumer-side timeout. Two optional keys in the same database section, 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.
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 all AsyncChatEvent) that has no later main-thread point to resolve at. Fail-open; apply and applyOnMain remain 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.
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.
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.
docs/SNLIB-DOCS.md section 12 now itemizes it, so a consumer can size the surface from facts:
Ph[] clone, one Binding record, one ItemStack clone.ItemStack clone always, plus one Component per name and lore line the template actually declares - and nothing else for a bare template.SnItem.fromConfig render it stands in for (~20 YAML reads plus a full meta write).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.
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.
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():
Enable aborted: <reason>; the throwable stays available at
FINE; no second disablePlugin.SEVERE with 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.
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.
SnFuture.join() inside its own onInnerDisable() got SnFuture.join() on the main thread outside shutdown/bootstrap in the console on every stop. The join itself was always safe (the pool is still open, the future completes, nothing is lost) - the message was pure noise, and it pointed at the one place the documentation asks you to flush from.onInnerDisable(). SnPlugin.onDisable() opens the window before calling your disable logic instead of after it, so inside onInnerDisable() the context already reports sn().isShuttingDown() == true. Every module is still live there: the pool is open and joins complete normally.Inside onInnerDisable(), from this version on:
SnFuture.join() on the main thread is recognized as shutdown work and no longer warns.SnYml.save() writes inline on the calling thread instead of queueing onto a scheduler that is about to be cancelled.Cooldowns, SnCron, LeaderboardCache and UpdateChecker no longer arm new work from there.discord() is sent by the ordered drain() step of the teardown rather than by an async worker racing the shutdown.No consumer code change is required and no recompile is needed: this is a drop-in SnLib.jar replacement.
Sn used one shuttingDown field for two different jobs: "the teardown window is open" and "the teardown already ran". Opening the window earlier without splitting them would have made shutdown() return immediately and skip the entire 13-step teardown. The idempotence guard is now its own field (shutdownRan) and the window is opened by a package-private beginTeardown().
API level unchanged (12) - no new public API.
plugins/.snlib-update/ staging folder is removed once it is empty. The self-updater
created it to download into and never cleaned it up, so every server that had ever checked for
an update was left with an empty folder next to its plugins for good. install now wraps the
swap in a try/finally so every exit removes it - verified swap, verification failure, the
Windows lock fallback and the rollback path alike - and the on-boot sweep removes it too after
clearing any leftover .part files, including the one 1.16.0/1.16.1 left inside the
.paper-remapped cache.plugins/ directly.No API change: SnApi.LEVEL stays 12. This is a drop-in replacement for 1.20.0.
A menu can now declare named groups of cells under regions:, and the plugin fills them with bindEach(regionId, data, filler) - one entry per cell. This removes the last reason a plugin had to hardcode an int[] of slots for a file-backed menu: the position, the cell count, the order and whether the group exists at all move into the yml.
layout:
- "ftttttttf"
- "ftttftttf"
regions:
toggles: ts.bindEach("toggles", actions, (a, e) -> e
.template(isOn(a) ? "toggle-allowed" : "toggle-denied")
.add("action", a));i renders into cell i: ascending row-major for a key:, your own order for a slots: list. Reordering the cells reorders the picture and never the data - every entry carries its identity in its placeholders.update-interval: instead of freezing at bind time.view-requirements all fall through to the item declared underneath, on the screen and on the click alike. Precedence on a shared cell: manual bind > paged bind > region > declared item.pagination: needed - a region is the non-paged sibling of paged-key: and never touches the page.GuiDef.regionSlots(id) lets a plugin report the owner's cell count in its own words.layout:, or blank its value. Deleting the regions: declaration is not one of them (the always-merge updater re-adds it), so never mark regions: as # sn:extensible.clearGhost now re-resolves the declared candidates of a cell a region released, so handing a slot back can never leave it visually empty but still clickable.New public surface: GuiSession.bindEach(String, List, BiConsumer), GuiEntry, GuiDef.regionSlots(String). API level 11 -> 12. Additive only; the japicmp gate against the 1.0.0 baseline passes.
Two warnings fired on every boot for configuration that was working exactly as intended, and neither could ever be resolved. Both are gone.
The record of what an owner-declared # sn:extensible withheld drops from WARNING to FINE.
This was a mistake in 1.19.0. Freezing a section in order to delete entries permanently is the point of the marker, and the plugin's jar keeps shipping those entries forever, so the condition never clears: the warning repeated on every restart, for a deliberate choice, with nothing to act on. Raise the log level if you ever need to see it.
An item whose key: letter you removed from the menu's layout: is hidden without a word. Removing the letter is the intended way to remove a button, yet it cost two warnings per button per boot:
Item 'members': key 'b' does not appear in layout; key ignored
Item 'members' has no valid slots; not renderedBoth are gone for that case. Genuinely malformed config still warns: a multi-character key:, a key: declared in a menu with no layout: at all (new dedicated message), declaring both slots: and key: on one item, and an invalid slots: value.
Also corrects two drifts found while editing: consumer-pom-template.xml was still pinned at 1.18.0, and the golden menu spec still claimed templates do not support key:, which stopped being true in 1.18.0.
No API change (still API level 11), 450 tests green.
# sn:extensible markersThe # sn:extensible and # sn:extensible-root markers are now honored when the disk file declares them, not only the jar resource. A server owner can freeze a section the plugin author still manages, and the entries they delete there stay deleted.
The rule is OR, never AND, so protection only ever adds:
| Declared in | Means | Effect |
|---|---|---|
| the jar resource | the author states the entries are the owner's | binding: deleting the comment on disk does NOT re-enable merging |
| the disk file | the owner freezes that subtree in their own copy | keys the plugin would have inserted are withheld and reported |
An author-declared section therefore stays declared whatever the disk file says, preserving the anti-tamper property of the 1.15.0 contract.
[update-configs] main.yml: 3 key(s) not inserted because the file declares sn:extensible at 'items'It fires only while keys are genuinely being withheld, so a file frozen after it was already complete logs nothing. The misplaced-marker lint now runs over the disk file too, minus the findings the resource already carries, so a shipped authoring mistake is not logged twice.
messages_en.yml, so a marker typed there has always counted. Whether a hand-typed marker was honored depended on which file happened to be the reference; now it counts everywhere.sn.api.level in the pom that 1.18.0 left at 10, so the shipped Sn-Api-Level manifest entry now matches SnApi.LEVEL.templates: may now declare slots: or key: resolved against the menu layout: exactly like items (declared slots win over key). Until 1.17.0, key: on a template warned "does not apply" and slots: was silently ignored - the placement of every dynamic element lived hardcoded in the plugin's Java, so editing the layout moved nothing.GuiSession.bind(String templateId, Ph...) renders a template into its yml-declared cells: the server owner repositions dynamic elements by moving the key in the layout (or editing slots:), no plugin update needed. A key covering N cells renders the same bind into every cell. Unknown template id or a template declaring neither slots nor a valid key WARNs once per GUI and is ignored.bind(int, template, Ph...) keeps ignoring declared cells, so plugin-computed placements (one template bound N times with different data) keep working unchanged.GuiTemplate.slots() / hasSlots() expose the declared cells. An invalid key: no longer drops the section: items fall to the existing has-slots gate, templates stay bindable by slot.plugins/.paper-remapped/ - the rewritten copy the server loads - instead of plugins/. The new jar was moved into that cache, the installed plugins/SnLib-<old>.jar was never touched, and the console still reported installed on disk. Since the server rebuilds the cache from plugins/ on every boot, the restart silently came back on the old version.plugin.yml declares name: SnLib and main: com.sn.lib.SnLibPlugin. If two SnLib jars are present, nothing is swapped and a warning is logged.Bukkit.getUpdateFolderFile(), so a non-default plugins path resolves correctly..snlib-update staging folders that 1.16.0/1.16.1 created inside the remap cache are cleaned up on startup.A broken updater cannot repair itself, so this one update has to be done by hand on any server that remaps plugins:
SnLib-*.jar from plugins/ and put SnLib-1.16.2.jar in its place - exactly one SnLib jar in the folder.SnLib-*.jar and the .snlib-update folder inside plugins/.paper-remapped/; they are inert leftovers./snlib update.Self-updates apply normally from 1.16.2 onward.
view-requirements now block the click, not only the render. An item hidden from a
player was still clickable on its empty slot: the click dispatch resolved the definition by
slot and ran its click-actions without ever re-testing the view requirement that emptied
the slot. Hiding an item was cosmetic; now it is a real gate.deny-actions -
so a view requirement never needs duplicating into click-requirements.update-interval; clicking a stale stack also clears
that slot, so the menu converges instead of looking unresponsive.nav-disabled is unchanged: only items declared in items: self-disable as navigation.GuiSession.itemAt(int) now honours its documented "null for an empty slot" contract for
hidden definitions and empty paginated slots.No new public API: SnApi.LEVEL stays 10 and the additive-only japicmp gate passes, so no
consumer needs a re-release - drop in the new SnLib.jar and restart.
SnLib now keeps its own jar up to date. This is not the sn.updates() module and is not available to consumer plugins: their notify-only guarantee is unchanged, and no plugin jar is ever downloaded, moved or deleted.
plugin.yml inside the downloaded jar. If either check fails, the download is deleted and the installed jar is left untouched.plugins/ and the old one deleted. If the OS refuses because the running jar is locked (the normal Windows case), it falls back to the server's native update folder instead.New auto-update block in plugins/SnLib/config.yml, added automatically to existing installs on the next boot:
auto-update:
enabled: true
interval-hours: 12
same-major-only: trueSet enabled: false to keep updating by hand.
New /snlib update subcommand (permission snlib.admin.update, the same one that receives update notices): shows whether the self-updater is enabled, the interval, the installed and latest-seen versions, whether a version is already on disk awaiting a restart, and forces an immediate check.
API level stays at 10 - the whole feature lives under **.internal.** and adds no public surface, so plugins built against any earlier 1.x SnLib are unaffected.
Owner-owned yml sections. The always-merge updater treated every key of a jar resource as mandatory schema, so a section whose ENTRIES are the server owner's data revived every shipped entry the owner deleted on the next boot. Additions survived, deletions did not.
A # sn:extensible comment line above a key in the jar RESOURCE now declares the whole subtree below it as owner data:
# Point types. Each entry is a type clans accumulate.
# sn:extensible
points:
kills:
display: "₢f2Kills"points: {}) is preserved - that is how you keep zero entries.# sn:extensible-root in a file header applies the same rule to the whole top-level keyset, for files where every root key is an entry id.managedPruning too: owner entries are data, not stale keys.config.yml, guis/*.yml, lang/, items.yml.New public surface: YamlUpdater.EXTENSIBLE_MARKER, EXTENSIBLE_ROOT_MARKER and markerWarnings(List<String>), which lints a resource and logs one WARN per marker placed on a key that holds a plain value (protects nothing). Empty catalogues {} / [] are legitimate and never reported.
API level 9 -> 10. Purely additive: a resource with no markers behaves byte for byte as before, so no existing consumer needs a re-release.
The generated command help is now translatable. The description of every command and the visible label of every argument were literals in Java, so a server owner had no way to change them without the source. Now SnLib seeds them into lang/messages_en.yml after the consumer registers its roots:
commands:
clan:
description: "Main command of SnClans"
subcommands:
create:
description: "Creates a clan"
args:
name: "name"
tag: "tag"
admin:
description: "Admin tools"
subcommands:
disband:
description: "Disbands a clan"The owner edits those values and the help follows on the next /<cmd> reload - no restart, no source. Nothing to opt into: declare your descriptions in code as usual and the keys appear on first boot.
YamlUpdater.merge, so edited values and comments are never overwritten; deleting an entry restores the value declared in code on the next boot. Translations (messages_es.yml and friends) pick the keys up through the usual merge-from-English pass./help <cmd> shows the translation too.Argument labels are visible-only. The identifier stays the name given to arg(name, ...) and remains the context.get(name) key, so translating a label never touches parsing or argument order:
commands.clan.subcommands.create.args.name: "nombre"
/clan create <nombre> [tag] <- usage and help
<nombre> <- tab suggestion
context.get("name") <- unchangedThe usage line and the tab hint read the same resolved label, so they cannot drift apart. Subcommand names are never translated - they are the tokens the sender types.
The block nests under reserved subcommands and args sections rather than mapping node names directly, so a subcommand actually named description, args or subcommands cannot collide with the structure.
API level 9. Purely additive (japicmp additive gate green against the 1.0.0 baseline): new SnLang.rawOrNull(key) - raw value or null, no <missing:> marker and no WARN - and SnCommands.applyLang(). Consumers need no code change at all; the seeding happens for them.
412 tests green, including 10 new cases covering the generated block as parsed YAML, partial and blank translations, tab/usage consistency, and that a translated label never changes the parsed argument key.
Alias-aware command rendering. Usage lines, generated help entries, the help footer and unknown-subcommand paths now echo the root label the sender actually typed instead of the declared root name. On a root named clan with command.aliases: [c]:
/clan help -> CLAN » /clan create <name> [tag] Create a clan
/c help -> CLAN » /c create <name> [tag] Create a clanNothing to configure - it follows the invocation, on both the plugin.yml and the dynamic CommandMap registration paths. The label is normalized before it renders: trimmed, lowercased and stripped of the plugin:name namespace form Bukkit also dispatches, so /CLAN and /myclans:clan both render as clan.
CommandContext.label() and RootContext.label() expose that label to consumer subcommands and to the onEmpty hook - echo it instead of hardcoding the root name whenever a message names the command.usage(...) is a literal by definition, so it opts in with a {label} placeholder: usage("/{label} reload [plugin]"). A literal without the placeholder is left exactly as written. /snlib reload adopts it.snlib.help.footer's {command} placeholder is unchanged in name, so existing consumer messages.yml files keep working and simply start rendering the alias.API level 8. Purely additive: no public member was removed, renamed or changed in signature (japicmp additive gate green against the 1.0.0 baseline). Consumers need no code change; rebuild only to call the new label() methods. The informative sn.api.level pom property was stale at 6 and now matches SnApi.LEVEL again.
402 tests green, including 6 new cases covering label normalization, alias-rendered help and usage paths, and the {label} placeholder.
aliasesFromConfig(), aliases(Supplier)) now register silently: the admin owns them at runtime, so they cannot be declared in the plugin.yml and the Aliases [...] not declared in the plugin.yml warning was noise on every boot.sn.items().redeemable(id, RedeemSpec, RedeemHandler): SnLib now owns the whole redeem interaction inside its shared interact listener. Air AND block right-clicks redeem (air interactions fire pre-cancelled in Bukkit; the shared listener handles them correctly), from either hand, sneaking or not. The interaction is cancelled so a placeable material (player head) is never placed, a protection plugin's DENY is respected, and a click on a RedeemSpec.blockedOn material opens the block instead.single(), handStack(), allMatching() / allMatching(cap) (whole-inventory sweep including cursor). The handler receives the consumed total plus the consumed stacks, so per-stack PDC data (value-carrying currency notes) can be aggregated.Args.intMin(min) / Args.doubleMin(min): numeric args with no upper bound that suggest the <argName> hint on tab instead of a sentinel bound (no more 2147483647 / 9.99E17 suggestions).intRange, doubleRange, intMin, doubleMin) now parses the case-insensitive k/m/b/t/qa/qi suffixes: 2k = 2000, 1.5b = 1500000000. Integral checks are epsilon-tolerant so 1.005k parses as 1005.snlib.number-too-small ({min}, {value}), auto-merged into consumer lang files.doubleRange previously read every comma as a decimal point; a single comma followed by exactly three digits now parses as thousands grouping (1,500 = 1500). 1,5 still reads as 1.5.SnItem.lore: a lore line containing \n splits into one lore line per segment, so a config LIST can flow through a single {placeholder} in menu templates and items.item-model appearance key (and public SnItem#itemModel(String)): stamps the 1.21.2+ minecraft:item_model component, so resource-pack ItemModels from Nexo/ItemsAdder can be referenced directly, e.g. material: PLAYER_HEAD + item-model: nexo:2d_player_head.custom-model-data: both components can coexist on one item (ItemModel base + CMD variants).SnCompat.probe, compile baseline stays paper-api 1.21.1 and runtime floor 1.20.4; below 1.21.2 or with an invalid key the field is skipped with ONE WARN.SnApi.LEVEL); additive-only japicmp gate passes.SubCommandBuilder.helpVisible(boolean) (default true), a second flag independent of visible: helpVisible(false) hides a subcommand (and its subtree) from the generated help ONLY - it still tab-completes, still appears in its group's usage line and still executes. Lets a plugin keep a deep subcommand group (for example a per-game setup tree) out of a crowded root help while it stays discoverable through tab.visible(false) keeps its exact previous semantics (hidden from help AND tab completion AND the group usage line); a !visible node stays out of the help regardless of helpVisible.[rgb] gradient now clear the accumulated legacy format, matching vanilla semantics. A bold prefix (₢f2&lBrand &8| &7) inserted after [rgb] no longer bleeds its &l into the message body.[noprefix] leading tag: a single-line lang value starting with [noprefix] is sent without the configured prefix; the tag is stripped by every render and composes with [center]/[rgb]/[small] in any order. Consumers using the tag need SnLib 1.9.0 or newer installed (on older versions the tag renders literally).<click:>/<hover:> tag are checked against the live file; a value that lost the tag (admin edit or translation drift) now logs one summary WARN naming the affected keys - previously the button look survived while the click silently died, with no signal anywhere.give(). Inventory-wide scan (storage, armor, off hand, open cursor) matched by the owner-namespaced PDC tag; programmatic, so locked/no-drop flags never block it. Command-given locked items can now ship a proper removal path (give/remove toggle, quit cleanup).Tests green, japicmp additive gate green. Drop-in replacement for 1.7.1 (existing consumers keep working unchanged).
snlib.admin.update permission description now follows the canonical fleet wording (Receive update notifications of SnLib).370 tests green, japicmp additive gate green. Drop-in replacement for 1.7.0.
<plugin>.admin.update that are ALREADY online when a new version is detected (previously only players joining after detection were notified). Join notices are unchanged.Version X.Y.Z available, installed A.B.C.); the release URL moved to the admin chat notice.No API surface changes (japicmp additive gate green, 370 tests green). Consumers need no code change; the new behavior applies as soon as the server runs SnLib 1.7.0.
SubCommandBuilder.sub(name, spec)), with recursive dispatch, tab completion, permission chains and full-path usage/help. Enables the /<cmd> admin <sub> pattern with a <plugin>.admin group permission and <plugin>.admin.<sub> leaves.RootBuilder.onEmpty(Consumer<RootContext>) runs a custom action when the root command is executed with no arguments (e.g. open a main menu); the generated help remains the default and stays reachable via RootContext.help().SnText.color() normalizes legacy section-sign codes (including the x-hex form) before MiniMessage parsing, so pre-rendered legacy text or PAPI expansion output can no longer crash GUI, lang, or hologram renders.SnText.plain, SnText.visibleLength, SnText.section.SnSpec.teleports() / sn.teleports()) with per-player pending dedup, warmup countdown message, move and damage cancellation, cooldown integration and guaranteed cleanup. Neutral lang defaults snlib.teleport.* merge into consumer lang files.&e{usage} &7{description} (separator removed).All public API changes are additive (japicmp gate green, 370 tests passing). Flat command trees, section-free text and plugins without .teleports() behave identically to 1.5.0.
&0-&f, &#RRGGBB) now reset active decorations in the component (MiniMessage) render path, matching vanilla legacy semantics. A bold set in a prefix no longer bleeds into the message body. CenterUtil measurement aligned.guis/*.yml resources of a consumer plugin are now seeded into its data folder on load (managed semantics, gated by update-configs). A declared .guis() module that loads zero menus now logs a WARN instead of staying silent.command.aliases config key (RootBuilder.aliasesFromConfig()); the config list is authoritative when present and is re-sourced on reload (added aliases register, removed ones unregister).text placeholder is gone; un-hinted free-form args suggest <argName>, with explicit hint overloads Args.string(hint) / Args.greedy(hint), a sender-aware Args.oneOf(Function) (suggest and parse), and suggest-only completions via Args.suggesting(...).{prefix} token (SnLib auto-prepends the configured prefix; the token always renders literally).All public API changes are additive (japicmp gate green). The decoration-reset render change is an intentional global fix: any text that relied on bold persisting across a legacy color code will now render per vanilla semantics.
SnSpec.updates(ownerRepo, tagPrefix) and matching UpdateChecker.watch/checkNow overloads: a repo shared by several plugins can now be watched, filtering by a tag prefix (for example myplugin- matching tags like myplugin-v1.4.0) and picking the highest matching version. This lets an ecosystem of plugins publish releases to ONE shared public repo instead of one dedicated public -Releases repo per plugin.updates(ownerRepo), polling releases/latest) is unchanged.SnApi.LEVEL 2 -> 3 (additive public API growth only; existing consumers are unaffected).Now that the SnLib repository is public, SnLib watches its own GitHub releases the same way every consumer plugin can watch its: strictly notify-only, checked 60s after enable and every 6h, never auto-downloads or auto-swaps anything.
UpdateChecker against ValentinTarnovsky/SnLib in its own
self-context (SnLibPlugin.buildSelfSpec()).snlib.admin.update (default op, child of snlib.admin)
gates the join notice..m2 install).No public API changes; SnApi.LEVEL is unchanged from v1.3.0.
Generated help improvements: {plugin} placeholder on the header, {description} placeholder on entries, page size raised from 8 to 10. No public API changes (SnApi.LEVEL stays 2).
Adds SnBridge: typed proxy<->backend messaging over plugin messaging (Tier 1 typed channels + Tier 2 generic verbs), served by the SAME dual-platform jar (Paper plugin.yml + Velocity velocity-plugin.json).
SnBridge is EXPERIMENTAL: com.sn.lib.bridge.* and com.sn.lib.velocity.* are @SnExperimental, outside the japicmp gate and outside SnApi.LEVEL (still 2, unchanged from 1.1.0). The frozen API (everything else) stays additive over 1.0.0, japicmp-verified.
New:
sn.bridge().channel(ns, msgset) on Paper, SnProxy.channel(this, ns, msgset) on Velocity. Fire-and-forget, request/response (both directions), state callbacks, legacy-channel migration detection.SnProxy.verbs()): console (backend-authoritative anchored allowlist, deny-all by default, rate limited, fail-closed against command/op tags hidden in action lists), message, title, actionbar, sound, bossbar (per-player), actions. Every verb resolves a terminal, typed SnDelivery - never void, never silent./snlib bridge status (backend), /snlibv status (proxy).docs/SNBRIDGE-SPEC.md (design), docs/SNBRIDGE-RUNBOOK.md (operator runbook), docs/bridge-example.yml (golden config), SNLIB-DOCS.md section 19 (consumer reference).Fixed during an independent final-check (two dedicated Paper/Velocity test plugins exercising every module): pre-handshake frame isolation, expired-queue-entry re-checking on flush, responder codec-failure NACKing, cross-backend request/response binding, immediate state delivery on subscribe, and a public SnBackendInfo type (no longer leaking an internal class).
323 tests across 37 suites, all green. mvn clean package (shade + japicmp) verified. A live-server smoke gate (Paper backend + Velocity proxy + end-to-end SnBridge round trip) is prepared but not yet executed - see README's "Smoke QA v1.2.0 (pending)".
Migrations of real consumers (SnKeyAll, SnCredits) onto SnBridge are deferred; the API freeze (SnApi.LEVEL 3) will not happen without one first.
Release additive sobre 1.0.0 (japicmp verificado: cero removals). API level 2.
Nuevo:
204 tests en 21 suites. Smoke verde en Paper 1.21.8 b60 y 1.20.4 b499 (Java 21).
Release inicial de SnLib, la libreria/nucleo comun de los plugins Sn como plugin standalone hard-depend.