Skip to content
Pétrus Pradella edited this page Nov 2, 2026 · 6 revisions

Menus

A menu is a screen with offers on it, registered by name so anything can open it. The core is the mechanism (package br.com.finalcraft.evernifecore.menu, in :common): a plugin builds its menus in code - or reads its own files and maps them to these objects - and the core registers, opens and trades. The core reads no menu file; that, and a reload verb, belong to the plugin that owns the files (FinalMenus, a shop plugin).

Currency gold = Currencies.require("finalpoints:gold");
Offer diamondBuy = Offer.buy(Goods.ofItem(ItemSpec.of("minecraft:diamond", 1)),
                Price.of(Currencies.money(), 250).and(gold, 2))                 // two currencies, one price
        .limit("diamond", RepeatPolicy.limited(3).window(Window.daily(LocalTime.MIDNIGHT, zone)), LimitScope.PLAYER)
        .confirm()
        .build();
Offer diamondSell = Offer.sell(Goods.ofItem(ItemSpec.of("minecraft:diamond", 1), ItemMatcher.of("minecraft:diamond").plainOnly()),
                Price.of(Currencies.money(), 100))
        .build();

Menu shop = Menu.of(plugin, "shop-vip")
        .title("&6VIP Shop &7- {0}")
        .openGate(Gate.builder().require(vipOnly).build())
        .offer("diamond", 13, diamondBuy, diamondSell)                          // one button: left buys, right sells
        .build();
MenuRegistry.get().register(plugin, shop);

MenuRegistry.get().open(player, "myplugin:shop-vip", Collections.singletonList(player.getName()));

Menu and MenuRegistry

  • Menu.of(owner, name) - the id is owner:name ([a-z0-9_-]{1,64} each side, any case), the owner being the plugin that builds it. The builder takes the title ({0}..{n}, {player}, %placeholders% - resolved per viewer by the screen), a screen of your own (layout and plain elements; 3 rows by default), an openGate, the clicks (a ClickMap), the successText/denyText the player reads after a click, and the offers: offer(id, slot, offers...) - one button holding at most one BUY and one SELL - or offers(region, source) / offers(region, previous, next, source) for a paged catalog (see Paged offers). build() refuses, saying what to change, a button with no offer or two of one type, an effect offer with no icon, or two buttons on one slot.
  • MenuRegistry.get() - register(owner, menu) (a second registration of an id names both owners), find(id), getAll(), getOwnedBy(owner), unregister(owner, name) and unregisterAll(owner) (the plugin's shutdown calls it). unregister takes out one menu - a reload that drops a menu file, an editor that deletes one; name is the bare name or owner:name, it answers whether the menu was there, and a menu another plugin registered is refused with an IllegalArgumentException naming both. open(viewer, id, args) starts a new navigation; open(ctx, id, args) opens on top of the screen a click came from, so back returns there. The open gate is checked first and paid once the screen is up: a denial tells the viewer why and opens nothing, an open the platform refuses (the player asleep, another plugin cancelling it) charges nothing, and a payment that fails after the window opened closes it again. An unknown id throws a message naming the id, the menus registered and how to register one.
  • The effect evernifecore:open-menu ({type: open-menu, menu: finalmenus:shop-vip, args: ["{player}"]}) is the same open from any definition - a quest, a dialogue, a region flag.
  • MenuContext reads off an ExecutionContext what the menu glue set as attributes: getMenu, getOffer, getClick (the gesture), getArguments, getView. There is no context subclass.

Paged offers

Menu catalog = Menu.of(plugin, "blocks")
        .screen(Screen.of(6))
        .offers(Slots.box(1, 1, 5, 9), Slots.of(45), Slots.of(53), () -> blockOffers)  // region, previous, next, source
        .build();
catalog.getOfferList().arrows(Icon.of("minecraft:arrow").displayName("§7Back"), null);  // null keeps the framework's

offers(region, previous, next, source) draws the framework's page arrows on the raw slots given (a null side draws none) and reads source again on every page. Menu.getOfferList() (@Nullable PagedList<Offer, ?>, null when the menu has none) is the screen's own list - id offers, arrows offers#previous and offers#next - so arrows(...), pagedBy(...), spillIntoPageArrows or pager on it change what the menu draws. A menu pours one paged offer list: a second offers(...) throws, saying to put every offer in one source or give the others their own offer(id, slot, ...) buttons.

Offer, Price, Goods

Piece What it is
Offer.buy(goods, price) / Offer.sell(goods, price) the trade, with gate(...) (conditions and fixed costs), limit(counterId, policy, scope), defaultQuantity, confirm(), readOnly() (a catalog line: quotes, never trades), to(destination), whenNoSpace(NoSpacePolicy.REFUSE)
Price lines of currencies - Price.of(currency, amount), .and(...), Price.of(currency, Variable<BigDecimal>) read at every quote (a DynamicValue is one) - and, on a BUY, of items (andItem(matcher, count)). A SELL offer refuses an item line: a sale pays the player and only a currency can be paid
Goods the other side: Goods.ofItem(item[, matcher]) (ItemGoods, one unit = item.getAmount() items, matched by an ItemMatcher on what the player hands over) or Goods.ofEffects([description,] effects) (EffectGoods, run once per unit, never sold). A plugin decorates or writes its own
Quantity ONE, STACK (the goods' stack), ALL_THAT_FITS (BUY: the smallest of the room, what each price line's balance affords and the uses left; SELL: the units held - ALL_HELD), ASK (the player types a number), of(n)
LimitScope whose counter a limit is kept under: PLAYER (default), GROUP (the context's group owner), GLOBAL; findOwner(ctx) answers that owner, or null when the context has none of the scope (ownerOf(ctx) throws, teaching how to build the context)

The limit is a RepeatPolicy counted on the owner's Cooldown handle: "3 a day per player" is limit("diamond", RepeatPolicy.limited(3).window(Window.daily(...)), LimitScope.PLAYER). Buying 2 uses 2; a quote uses none.

Goods that do not fit become a pending delivery by default (NoSpacePolicy.DELIVER_LATER): a full inventory never loses what was paid for. A shop that would rather refuse says whenNoSpace(NoSpacePolicy.REFUSE), and the trade answers NO_SPACE without charging.

Transactions - the fixed order

Every verb runs on the caller's thread (a click, a command), without blocking I/O:

  1. the offer's gate is checked - every failure (GATE_DENIED), or a readOnly() offer;
  2. the limit (LIMIT_REACHED, with the counter's own text: "3 of 3 used until 00:00");
  3. the room (NO_SPACE, only with REFUSE) or the units held (NO_ITEMS);
  4. TransactionPreEvent - cancellable (CANCELLED), with a mutable quantity and mutable totals (getQuote().setTotal(lineId, amount) - a tax, a discount, a stock price; getQuote().getOriginal() keeps what the offer asked; a negative total is refused, and so is a fractional one on an item line - round it yourself). A quantity the listener changed goes through steps 2-3 again, so a "bonus" listener cannot pass the limit, the room or the items held;
  5. one Gate.pay of the fixed costs and every price line (BUY) - taken in order and refunded in reverse when one does not come out (INSUFFICIENT_FUNDS, getMissingLine() names it);
  6. the goods handed over (BUY) or taken, and every price line paid to the player (SELL - each line times the player's boosters of that currency, ctx.getMultiplier(currencyId)); goods whose give throws refund what was charged and answer NOT_DELIVERED (the exception goes to the plugin's log, the player reads "... could not be handed over; what you paid went back.");
  7. the limit counted, an AuditKind.TRANSACTION entry in the owner's audit log, and TransactionEvent - once, only after a success.

Steps 1-3 refuse before any event. Money that went missing on the way is never silent: an audit entry and a severe line name the amount, the player and the uuid.

Verb What it does
quote(ctx, offer, n) steps 1-3 and the funds check; nothing taken, nothing posted. A limit whose owner the context lacks (a catalog priced from the console or a server-wide screen) is skipped, every other check runs
buy(ctx, offer, n) / sell(ctx, offer, n) the whole order; a limited offer without its owner in the context throws IllegalStateException
trade(ctx, offer, quantity) resolves the Quantity (the prompt for ASK, CANCELLED when dismissed or with no screen to ask through) and buys or sells
sellAll(ctx, catalog) / sellHand(ctx, catalog, scope) see below

TransactionResult says isSuccess(), the Failure, the offer, the quantity, the Quote, the GateResult, the missing line and the pending delivery (getDelivery()); describe(ctx) is the text for the player - "Bought 3x Diamond for 750.00 coins", or why it failed. Transactions sends no message itself: the caller does. Every text it writes for the player - the refusals, the quantity prompt and its errors, and describe - is a core locale message in EN_US and PT_BR, read in ExecutionContext.getLocale() (see Localization).

The offer button

Each offer button is one ClickRule per gesture of the menu's ClickMap - the screen's one click path. ClickMap.market() (the default): left buys one, shift-left a stack, right sells one, shift-right everything held, middle asks how many to buy. A gesture bound to SELL does nothing on a button without a SELL offer; a readOnly() offer gets no rule at all. A confirm() offer resolves the quantity first and opens ScreenKey.CONFIRM on top naming it and its total ("Sell 30x Diamond for 300.00 coins?"); a yes trades exactly that quantity. With ASK the number is asked before the confirmation; where nothing fits there is no question, only the trade's refusal. Whatever override answers ScreenKey.CONFIRM asks it, so a shop can restyle its confirmation with Screens.override(plugin, ScreenKey.CONFIRM, ScreenScope.ownedBy(plugin), ...). After the trade the player reads the menu's successText ({result}) or denyText ({missing}). The button's own text reads {price} (one unit of the BUY offer), {sell-price} and {quantity}, in each currency's format.

Selling from code - OfferCatalog, sellHand, sellAll

A /sell command needs no screen. An OfferCatalog answers which SELL offer an item is sold under (OfferCatalog.of(offers...) - first match wins - or menu.asCatalog(), or a plugin's own keyed table):

OfferCatalog catalog = OfferCatalog.of(
        Offer.sell(Goods.ofItem(ItemSpec.of("minecraft:diamond", 1), ItemMatcher.of("minecraft:diamond").plainOnly()),
                Price.of(Currencies.money(), 100)).build());

TransactionResult hand = Transactions.sellHand(ctx, catalog, HandScope.HELD_STACK);         // the stack in the hand
TransactionResult same = Transactions.sellHand(ctx, catalog, HandScope.SAME_ITEM_EVERYWHERE); // every unit that offer matches
SellAllResult all = Transactions.sellAll(ctx, catalog);    // one sell per offer matched; getUnits(), getTotals() per currency

Each of them sells up to the offer's limit: 15 diamonds under "10 a day" sell 10 and leave 5, the same as the shift-right button; a limit already used up answers LIMIT_REACHED. HELD_STACK with less than one unit of the offer in hand (2 diamonds where a unit is 4) answers NO_ITEMS and takes nothing from elsewhere.

count, take, sellHand and sellAll read the main inventory (storage and hotbar, 36 slots): armor and the off-hand are never counted nor taken, and worn armor never pays an item price.

new SellAllResult(List<TransactionResult>) is public: a plugin that sells over its own catalog builds the same summary from its results, in the order they were traded.

plainOnly() refuses a renamed item (or one with lore); an item the catalog does not buy is NOT_SELLABLE, with no event. A tax is a TransactionPreEvent listener that lowers the totals of a sale; the original stays visible.

FinalMenus, the file reader

The core never reads a menu from a file; FinalMenus is the product that does (the README.md of its repository holds the whole grammar). Each menus/**/*.yml and shops/**/*.yml becomes a Menu.of(finalmenus, name) (menus/vip/tools.yml -> finalmenus:vip-tools, shops/blocks.yml -> finalmenus:shop-blocks: a menu name has no /), so open-menu and MenuRegistry.open reach them like any other plugin's menu. What maps to what:

In the file In the core
title, open.gate Menu.Builder.title, openGate (keys conditions/costs/limit, or the core's require/cost)
layout.grid + elements a Screen.of(rows); each element a component drawn per viewer
candidates: [{priority, view, icon, click}] the highest priority whose view passes ScreenView.test is painted, and its rules answer the slot
click: [{clicks, gate, effects, deny}] ClickRule.on(gestures).gate(...).onSuccess(...).onDeny(...); deny reads {reason} too
every type: Conditions / Costs / Effects.parse; an unknown bare type fails naming the registered ones, a namespaced one stays pending
an offer Offer.buy / Offer.sell over Price.of(currency, Variable), Goods.ofItem(item, matcher) or Goods.ofEffects, limit(id, policy, LimitScope)
price / stock the plugin's DynamicValues (<shop>-<offer>-price, -stock), one for the whole server; moved from TransactionEvent, never by a quote
out of stock a Condition in the offer's gate - a GATE_DENIED naming the next restock
buy.confirm / quantities its own click path: ScreenKey.CONFIRM above a total, the units of each gesture (fit, all, hand, ask)
screens/confirm.yml Screens.override(finalmenus, ScreenKey.CONFIRM, ScreenScope.server(), ...)

The offers are not Menu.Builder.offers: FinalMenus pours them through its own PagedList per viewer, whose Pager it keeps outside the screen, so /fm reload reopens a shop on the page the player was on (ScreenNavigation.replace, same navigation). Its trades carry the caller finalmenus:<shop>#<offer>: a plugin that prices them differently listens to TransactionPreEvent.

From BossShopPro

BossShopPro Here
price NOTHING Price.free()
price MONEY a money line (Currencies.money())
price EXP an evernifecore:exp-levels line
price POINTS the points plugin's Currency (finalpoints:<id>)
price ITEM Price.andItem(matcher, count) (BUY only)
reward ITEM Goods.ofItem(...)
reward SHOP Goods.ofEffects(open-menu)
reward PLAYERCOMMAND / command Goods.ofEffects(command as player / as console)
reward POINTS / MONEY Goods.ofEffects(money or the currency's give, with to:)
a shop page a Menu (a paged catalog with offers(region, source))

Commands

MenuCommandNode is mounted under the plugin's own command and acts only on the menus that plugin registered:

@FinalCMD.Node(subcmd = "menu", permission = "myplugin.menu")
public final MenuCommandNode menus = new MenuCommandNode(plugin, "myplugin.menu");

open <menu> [args...] (a bare name is this plugin's), openfor <player> <menu> [args...] (needs myplugin.menu.others), list. There is no reload and no /ecore menu.

Testing

Menus.in(screenWorld, logicWorld) (in common-tests) gives the shop two currencies (<shop>:coins, <shop>:gold), logged-in players with a balance, offer shortcuts, clickOffer, answerPrompt, confirm and the event recorder; CountedGoods (refuseGives(why) for goods that cannot be handed over) and RecordingGoods are goods doubles. It loads the core's trade texts, which only the real boot loads otherwise. Open the screen world with ScreenWorld.open(dir, Menus::inventorySlots) for item goods.

See also

Screens · Economy · Deliveries · Logic · Cooldowns · Audit Log

Clone this wiki locally