Skip to content

Localization

Petrus Pradella edited this page Jul 27, 2026 · 3 revisions

Localization

What this page covers: the message system built on @FCLocale + LocaleMessage. You declare a message once, inline, with one translation per language; EverNifeCore syncs it to a per-plugin language file, resolves the language to render it in, resolves its ${placeholders}, and sends it as a FancyText (so hover/click come for free). This is how commands, argument parsers, and access validations talk to players.


The 30-second version

import br.com.finalcraft.evernifecore.locale.FCLocale;
import br.com.finalcraft.evernifecore.locale.LocaleMessage;
import br.com.finalcraft.evernifecore.locale.LocaleType;

public class TemplateMessages {

    @FCLocale(lang = LocaleType.EN_US, text = "&eYour profile &7- &fLevel: &a${level} &7| &fCoins: &6${coins}")
    @FCLocale(lang = LocaleType.PT_BR, text = "&eSeu perfil &7- &fNível: &a${level} &7| &fCoins: &6${coins}")
    public static LocaleMessage PROFILE_INFO;
}

Send it, declaring the placeholders - bare key in the declaration, ${key} in the text:

TemplateMessages.PROFILE_INFO
        .addPlaceholder("level", profile.level)
        .addPlaceholder("coins", profile.coins)
        .send(sender);

The field is a public static LocaleMessage; it starts null and is populated when the class is scanned (see Loading). Each @FCLocale on the field is one translation.


@FCLocale - one translation

Stack one @FCLocale per language on the same field (the annotation is @Repeatable, so you just write several):

Attribute Type Default Purpose
text String "" The message text (color codes with & or §).
lang String EN_US Which language this translation is for.
hover String "" Hover tooltip text.
click String "" Click action payload (a command by default).
clickType ClickActionType RUN_COMMAND RUN_COMMAND / SUGGEST_COMMAND / OPEN_URL / NONE.
children Child[] {} Extra appended pieces, each with its own text/hover/click.
@FCLocale(
        lang = LocaleType.EN_US,
        text = "&aClick here",
        hover = "&7Opens the wiki",
        click = "https://github.com/EverNife/EverNifeCore/wiki",
        clickType = ClickActionType.OPEN_URL
)
public static LocaleMessage WIKI_LINK;

LocaleType ships three constants - EN_US, PT_BR and ZH_CN - but lang is a plain String, so any language tag (e.g. "ES_ES") is valid; add another @FCLocale with that tag to provide the translation. LocaleType.register("ES_ES") makes the tag known to /eclocale as well.


Loading a message holder

The static fields are populated by a scan. Command classes, argument parsers, and access validations are scanned automatically when you register them - their @FCLocale fields just work. For a plain holder class (like TemplateMessages above), scan it once at startup:

import br.com.finalcraft.evernifecore.locale.scanner.FCLocaleScanner;

FCLocaleScanner.scanForLocale(ecPluginData, false, TemplateMessages.class);

The scan derives each message's key from ClassName.FIELD_NAME, then syncs the hardcoded defaults to a language file so server owners can edit the wording. From then on the file is the source of truth for the text.

⚠️ Gotcha - LocaleMessage fields must be public static. A non-static field is logged as an error and skipped, and a field with no @FCLocale annotation warns. Two fields of the same plugin resolving to the same key is an error naming both fields: the first one registered wins.


Language files & switching language

Each plugin gets its own language files under its data folder:

plugins/<YourPlugin>/localization/
├── localization_config.yml     # selects the active file (Localization.fileName)
├── lang_EN_US.yml              # generated from the @FCLocale(EN_US) defaults, editable
└── lang_PT_BR.yml              # generated from the @FCLocale(PT_BR) defaults, editable

A message resolves to the plugin's active language, chosen by the server owner. Admins manage it with the built-in command:

/eclocale list                       # show every plugin and its current locale (clickable to switch)
/eclocale set <PluginName> <Locale>  # set one plugin's language
/eclocale setall <Locale>            # set every plugin's language at once
/eclocale self <Locale>              # a player picks the language THEY read messages in

If the active language has no translation for a given message, EverNifeCore falls back to the first @FCLocale declared on that field.

Per-player language

/eclocale self is opt-in, and off by default. Turn it on in plugins/EverNifeCore/config.yml:

Settings:
  Locale:
    PER_PLAYER_LOCALE: true

While the setting is off, the per-player section is never registered, /eclocale self is hidden from tab completion and inert if typed, and every message uses the plugin's configured locale. Turned on, each player's choice is stored in their player data and read from cache when a message is rendered for them - so it never blocks and never touches storage on the send path. A player who has not chosen, and any non-player recipient, falls back to the plugin's language.


Placeholders

A message declares the value of ${key} (case-insensitive). The key is declared bare and cited with its delimiters in the text:

TemplateMessages.COINS_RECEIVED                // text: "&aYou received &6${amount}&a coins"
        .addPlaceholder("amount", amount)
        .addPlaceholder("coins", profile.coins)
        .send(sender);

Nothing is computed eagerly: a key the text never cites is never resolved, and a key cited twice (text and hover, say) is resolved once per render.

⚠️ Declare the bare name. addPlaceholder("%amount%", ...) registers the key exactly as written, so it never matches anything - and says so once in the console, naming the bare form. Two keys differing only in case (amount and Amount) throw at registration rather than silently shadowing each other.

For a value that depends on the receiving player, pass a Function<PlayerData, Object> - it's evaluated per recipient, using the player's cached data, so it never blocks:

message.addPlaceholder("balance", playerData -> economy.getBalance(playerData)).send(player);

For a value that needs the whole render context (recipient, their data, the command scope), use addParser, which also takes the description an integrating plugin can list back to the user:

message.addParser("reader", "The name of whoever is reading this line",
        context -> context.getSender() == null ? "console" : context.getSender().getName());

Every message - locale message, hand-built piece, or chain assembled at runtime - additionally answers for ${label} and ${subcmd}, the command label and sub-command the player typed. They are the lowest precedence there is: a message that declares its own label shadows them.

${key} is the only closure the message engine reads, and there is one engine: each message carries a PlaceholderProvider<RenderContext> served by a ${} RegexReplacer - the very same RegexReplacer described on Placeholders. What changes between the two pages is only the object the engine resolves against. You can push a fully-composed replacer into a message with addReplacer(compoundReplacer), which runs after the message's own ${key} pass - that is how PlaceholderAPI's %papi% tokens reach a message.


Customizing a message inline

Call .custom() (or any of the add*/set* methods, which start a custom copy) to tweak a single send without touching the shared field: override the hover/click, add a replacer, or append messages.

message.setHover("&7extra tooltip").send(sender);          // per-send hover
message.setClickSuggest("/pay " + name + " ").send(sender); // click suggests a command
messageA.append(messageB).send(sender);                     // join two messages into one line
message.append(FancyText.of("&7 (click me)"))               // append a raw FancyText too
        .send(sender);
message.broadcast();                                        // the broadcast audience, console included

Each of those returns an ILocaleMessageBase backed by a fresh copy, so the underlying LocaleMessage field is never mutated - concurrent sends with different placeholders are safe. .custom() returns the concrete SendCustom when you want to hold it in a variable.

The vocabulary is the same as FancyText's: setX replaces an attribute (a message carries at most one hover and one click), addX accumulates, append builds a chain.

Sending

message.send(sender);                    // one or more FCommandSender
message.send(listOfSenders);             // a List
message.sendIf(sender.hasPermission("myplugin.debug"), sender); // send only when it holds
message.broadcast();                     // every online player AND the console

⚠️ sendIf makes only the delivery conditional. Whatever the message declared is still evaluated, so a value that is expensive or unsafe to compute with the condition false still belongs behind a real if.

A message delivered from an asynchronous task has no command scope, so ${label}/${subcmd} would come up empty. Pass the context explicitly:

RenderContext context = RenderContext.of(sender, CommandMessageContext.of(label, "give"));
FCScheduler.runAsync(() -> message.send(context, sender));

What the explicit context contributes is its CommandMessageContext - not the recipient. Every recipient still gets their own render.

Asking about a message

FancyText preview = message.getFancyText(sender);   // exactly what send(sender) would deliver
boolean exists   = message.isDefined();             // was ANY language registered for it?

getDefaultFancyText() never returns null: a message with no registered language renders as [LOCALE_NOT_DEFINED:<key>], so it is visible instead of silently blank. isDefined() is the explicit question to ask when there is something else to do about it.


See also

  • FancyText - what a resolved LocaleMessage becomes; hover/click building blocks.
  • Command Framework - @FCLocale as a command/sub-command description, and message fields on commands.
  • Argument Parsing - localized error messages inside a custom ArgParser.
  • Placeholders - the Replacer/RegexReplacer engine and PlaceholderAPI bridging.
  • Quick Start - a minimal plugin wiring a command, a config, and a locale together.

Clone this wiki locally