Astral Realms Documentation Help

Anvil

AstralItems ships a packet-level reimplementation of the vanilla anvil window. The plugin owns the session state — Bukkit's InventoryView is bypassed entirely — and fires AnvilResultEvent every time the output slot needs to be recomputed. This is what lets the custom-item system layer "books only ever enchant up to available-enchantments " on top of the vanilla rename / combine logic without monkey-patching anything.

Opening the anvil

  • /items anvil — opens the window for the sender (requires items.command).

  • Right-clicking a vanilla anvil block (ANVIL, CHIPPED_ANVIL, DAMAGED_ANVIL) with EquipmentSlot.HAND is intercepted by AnvilListener. The block-place fallback is preserved: if the player is sneaking AND holding a non-air item, the click falls through to vanilla so they can still place the held block.

  • Any other vanilla anvil GUI — opened via Player.openAnvil, NMS, or another plugin — is also intercepted through InventoryOpenEvent (at HIGHEST) and rerouted to the custom anvil. The one exception is a location-less plugin menu UI built with Bukkit.createInventory(holder, InventoryType.ANVIL, …), which is left alone (it has no world location).

  • From code: plugin.anvil().open(player).

Lifecycle

open(player) ──► WrapperPlayServerOpenWindow (id=8, "container.repair") │ ▼ AnvilSession created │ ▼ ┌──────────────────────────────────────────────────────┐ │ Click / Rename / Close packets │ │ → AnvilPacketListener → AnvilService.handleClick/ │ │ handleRename/handleClose │ │ → Bukkit.getScheduler().runTask(...) │ └──────────────────────────────────────────────────────┘ │ ▼ recomputeResult(session) │ ▼ CustomItemRule → EnchantedBookRule → VanillaAnvilRule (first rule to claim decides the operation) │ ▼ AnvilResultEvent fires │ ▼ session.setItemAt(2, result) + session.cost(cost) │ ▼ session.sync()

Click handling matches the protocol exactly — vanilla click types (PICKUP, QUICK_MOVE, SWAP, CLONE, THROW, QUICK_CRAFT, PICKUP_ALL) are all routed to dedicated handlers. The session keeps an authoritative copy of the three anvil slots and the cursor; output-slot interactions consume both inputs and charge XP from the player.

The window is invisible to vanilla's container tracking, so AnvilService also runs a per-tick reconcile pass over every open session — the equivalent of vanilla's AbstractContainerMenu.broadcastChanges. Without it, mutations the server makes outside a click (item pickup, armour durability loss, another plugin writing to the player's inventory) would not reach the client until the next click.

The rule pipeline

AnvilService decides every operation through an ordered list of AnvilRules, consulted in registration order:

Order

Rule

Claims

1

CustomItemRule

Any operation whose left input is an AstralItems custom item and whose right input is not.

2

EnchantedBookRule

Book-on-book combines, and over-cap books applied to a vanilla item.

3

VanillaAnvilRule

Everything else — always claims (terminal).

AnvilRule.apply(AnvilResultEvent) returns true to claim the operation: the rule fully decided it, either by populating result/cost/materialCost or by cancelling the event. Returning false passes the operation to the next rule untouched. Because the terminal rule always claims, exactly one rule decides each operation before AnvilResultEvent is dispatched to external listeners — so listeners always see a populated result and can tweak or overwrite it.

VanillaAnvilRule — genuine vanilla via headless NMS

The terminal rule delegates to NmsAnvilLogic, which no longer replays vanilla's algorithm by hand. It builds a detached NMS AnvilMenu — never registered as the player's container, never sent to the client — fills its input slots, and reads slot 2, the level cost, and the material count back through the Bukkit AnvilView.

Two consequences matter:

  • The result is real vanilla behaviour, including material-based repair (iron sword + iron ingot), which the old hand-rolled logic explicitly did not model.

  • CraftBukkit's PrepareAnvilEvent fires, so external plugins hook the custom anvil exactly as they would a vanilla one — CraftEngine's custom-material repair and its anvil-rename sanitizing among them.

NmsAnvilLogic.MAX_ANVIL_COST is 1500, pushed into the menu via AnvilView#setMaximumRepairCost (vanilla's own field defaults to 40). The old 40-level ceiling is therefore gone on the server side.

The server-side gate on taking a result is the same in both directions: creative bypasses it, otherwise the player needs at least cost levels and cost < 1500.

CustomItemRule — custom-item rules

Claims unconditionally when inputLeft is a custom item and inputRight is not, so neither vanilla logic nor PrepareAnvilEvent listeners ever touch custom items. Combining two custom items is left unclaimed and falls through to the vanilla rule.

  1. Cancel with cost 0 when the operation is a real rename attempt, or when inputRight is empty. Custom items cannot be renamed in either direction (new name or blank-out).

  2. Cancel when inputRight is anything other than an enchanted book.

  3. Cancel when the blueprint declares no metadata.available-enchantments whitelist.

  4. Cancel when the book contains no enchantment the whitelist lists.

  5. Strip every enchantment the whitelist does not list from the result, then merge the book's whitelisted enchantments — equal levels combine into level + 1, otherwise the higher of the two wins. A merged level above the whitelist cap cancels the combine outright; it is never clamped.

  6. Re-write the ItemInstance PDC and refresh lore via ItemService.updateItemStack.

  7. Bump the result's prior-work cost to max(resultCost, bookCost) * 2 + 1, like a vanilla combine.

  8. Compute the XP cost as the target's prior-work cost plus, per applied enchantment, max(1, anvilCost / 2) * resultLevel (halved because the sacrifice is a book), floored at 1 so the result slot stays pickup-able.

Because the whitelist caps are the blueprint's own, they may legitimately exceed vanilla's natural maxima — efficiency: 7 on a hammer is the point of the system.

EnchantedBookRule — the two deliberate deviations

Runs only when inputRight is an enchanted book carrying stored enchantments.

  • Book + book combines merge unclamped. Equal levels stack past the natural cap (Efficiency V + V → VI on a book), where vanilla clamps. This is how over-cap books are crafted for blueprints that allow them. Conflicting enchantments are skipped and add a +1 penalty each, exactly like vanilla; if every enchantment from the right book is rejected the combine is cancelled, and if none applies the rule passes on. A rename rides along for +1 level. The total (priorWork(left) + priorWork(right) + operationCost) is cancelled when it reaches 1500 outside creative.

  • An over-cap book on a vanilla target is rejected outright, instead of vanilla's silent clamp — so a player cannot burn an Efficiency VII book for an Efficiency V result.

In-cap books on vanilla targets are left unclaimed and handled by pure vanilla. Custom-item targets never reach here; CustomItemRule runs first and applies the blueprint's own caps.

Skins are not applied in the anvil

CustomItemRule used to apply a skin item placed in the right slot. That branch is gone. It painted the model onto the result stack without ever writing the skin data back to the instance, so the item wore a skin the instance knew nothing about: invisible to removal, and silently lost on the next rebuild. A skin item in the right slot is now simply not an enchanted book, so the combine is cancelled at step 2 like any other invalid sacrifice.

Skins are applied by holding the skin item on the cursor and clicking the item, or with /items set-skin — both persist the data. Items skinned by the old anvil path are repaired automatically the next time they are upgraded; see Skins › Orphan skins.

Session cleanup

AnvilListener routes players into the anvil and forwards lifecycle events; AnvilService does the work, so anvil contents are never lost or duplicated:

  • On close (handleClose) — cursor and both input slots are returned to the player, overflow dropped. The output slot is never returned.

  • On quit (PlayerQuitEvent) — same, synchronously, so the mutations land before the player is saved.

  • On death (PlayerDeathEvent) — the cursor and the two input items follow vanilla death semantics: routed into event.getDrops(), or kept when keepInventory is honoured. The output slot is excluded.

AnvilResultEvent — extending the anvil

See the event reference for the full field list. Common patterns:

Add a custom recipe:

@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onAnvilResult(AnvilResultEvent event) { if (event.inputRight().getType() != Material.NETHER_STAR) return; ItemStack result = event.inputLeft().clone(); result.editMeta(meta -> meta.setCustomModelData(9999)); event.result(result); event.cost(10); }

Override the XP cost:

@EventHandler(priority = EventPriority.MONITOR) public void freeAnvilForVips(AnvilResultEvent event) { if (!event.getPlayer().hasPermission("anvil.free")) return; event.cost(0); }

Cancel a combine entirely by calling event.setCancelled(true).

Partial consumption of the right slot

AnvilResultEvent.materialCost() controls how much of the right input is consumed when the result is taken:

  • 0 (the default) — the entire right slot is consumed. This is the normal combine / book / rename behaviour.

  • a positive value — only that many items are removed and the remainder stays in the slot. This is what material-based repair uses, where a tool consumes only the ingots it actually needed.

AnvilSession

AnvilSession exposes the full window state to listeners through AnvilResultEvent.session():

Method

Returns

Use

itemAt(int slot)

ItemStack

Read any of the 39 slots (0 = left input, 1 = right input, 2 = output, 3..38 = player inventory mirror).

setItemAt(int slot, ItemStack)

void

Write a slot. Trigger a follow-up sync() to push to the client.

cursor()/cursor(ItemStack)

ItemStack

Read / write the carried cursor stack.

renameText()

String

The text in the rename box. null means the client never touched the field; "" means the player cleared it — the rules use that distinction to decide whether to strip an existing custom name.

cost()/cost(int)

int

Last-computed XP cost.

materialCost()/materialCost(int)

int

How much of the right slot the take will consume (see above).

Most plugins should mutate state only via event.result(...)/event.cost(...) — those go through the session and the reconciliation step automatically. Direct slot writes are reserved for advanced cases (e.g. consuming an extra ingredient from the player's inventory mirror).

Last modified: 03 September 2026