Repository navigation
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.of(owner, name)- the id isowner:name([a-z0-9_-]{1,64}each side, any case), the owner being the plugin that builds it. The builder takes thetitle({0}..{n},{player},%placeholders%- resolved per viewer by the screen), ascreenof your own (layout and plain elements; 3 rows by default), anopenGate, theclicks(aClickMap), thesuccessText/denyTextthe player reads after a click, and the offers:offer(id, slot, offers...)- one button holding at most one BUY and one SELL - oroffers(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)andunregisterAll(owner)(the plugin's shutdown calls it).unregistertakes out one menu - a reload that drops a menu file, an editor that deletes one;nameis the bare name orowner:name, it answers whether the menu was there, and a menu another plugin registered is refused with anIllegalArgumentExceptionnaming 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. -
MenuContextreads off anExecutionContextwhat the menu glue set as attributes:getMenu,getOffer,getClick(the gesture),getArguments,getView. There is no context subclass.
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'soffers(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.
| 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.
Every verb runs on the caller's thread (a click, a command), without blocking I/O:
- the offer's gate is checked - every failure (
GATE_DENIED), or areadOnly()offer; - the limit (
LIMIT_REACHED, with the counter's own text: "3 of 3 used until 00:00"); - the room (
NO_SPACE, only withREFUSE) or the units held (NO_ITEMS); -
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; - one
Gate.payof 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); - 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 whosegivethrows refund what was charged and answerNOT_DELIVERED(the exception goes to the plugin's log, the player reads "... could not be handed over; what you paid went back."); - the limit counted, an
AuditKind.TRANSACTIONentry in the owner's audit log, andTransactionEvent- 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).
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.
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 currencyEach 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.
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.
| 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)) |
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.
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.
Screens · Economy · Deliveries · Logic · Cooldowns · Audit Log
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Block Data (SVWorldDataManager)
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Logging & Debug Modules
- Events
- Regions
- Groups
- Notifications
- NPCs
- Logic
- Quests
- Provenance
- Scheduler & Threading
- Items & NBT
- Screens
- Dialogues
- Menus
- Integrations
- Economy
- Boosters
- Deliveries
- Audit Log
- Dynamic Values
- Player Ignore
- Version Compatibility
Architecture & Reference