Skip to content

Gotchas and Pitfalls

Petrus Pradella edited this page Aug 3, 2026 · 7 revisions

Gotchas and Pitfalls

The behavioral traps worth knowing before you hit them. These are by-design constraints of how EverNifeCore works, not bugs - understanding them up front saves a confusing debugging session.


Player data only persists after markDirty()

A PDSection is a plain Jackson POJO. Mutating one of its fields changes the in-memory object but does not schedule a save. Persistence happens on the periodic flush (and on quit/shutdown) only for sections that have been marked dirty:

public void addCoins(int amount) {
    this.coins += amount;
    markDirty();          // <-- without this, the change is never written
}

The idiomatic pattern is to markDirty() inside every mutator. If you forget it, the data looks correct in memory for the rest of the session and silently disappears on the next load. See PlayerData and PDSections.


getPDSection(...) is async - never .join() it on the main thread

Every section read returns a CompletableFuture, because it may have to consult the storage backend (a database on a shared server). Blocking on it from the server's main thread freezes the whole server for the duration of that I/O:

// DON'T - blocks the main thread until storage answers
CoinsSection s = player.getPDSection(CoinsSection.class).join();

// DO - stay async; the callback runs when the data is ready
player.getPDSection(CoinsSection.class).thenAccept(coins -> {
    // use coins here
});

If you genuinely need a value right now and only want it when it is already in memory, use the non-blocking loaded-only accessors (getPDSectionIfLoaded / hasPDSectionIfLoaded) instead of blocking a future. The whole player-data layer was designed to be 100% async; a .join() on the main thread defeats that.


getPDSection seeds a default; use hasPDSection to tell "absent" from "default"

getPDSection(cls) never completes with a missing section for an existing player: on a true backend miss it seeds a transient default (a cache-only instance, no I/O). That default is persisted only once something marks it dirty - so a brand-new player who never earned a coin doesn't get a wasted row written for them.

The consequence: a seeded default is not the same as a stored section, and the accessors distinguish the two:

  • getPDSection(cls) - always gives you a usable instance (seeding a default on a miss).
  • getPDSectionIfPresent(cls) - async Optional, empty on a true miss, seeds nothing.
  • hasPDSection(cls) - async Boolean; a transient default counts as absent (only a genuinely stored row returns true).

Reach for getPDSectionIfPresent / hasPDSection when "has this player ever had this data?" is the actual question. See PlayerData and PDSections.


The login waits for your sections - and says so when it drags

Every section that loads at login (the default, ONLINE) is resolved inside the platform's async pre-login event, which holds the connection until it finishes. That is what makes the cell reliably in memory when the player lands, but it also means a slow backend or a heavy section is time the player spends on the connecting screen. The whole chain is bounded by playerdata.login-timeout-seconds (15s); past it the login is denied rather than served with data missing.

Past playerdata.slow-login-report-seconds (3s) the console gets a table of that login: each section, its load time, the plugin that declared it, that plugin's author, and the backend it lives on. Read it before concluding EverNifeCore is slow - it is usually one plugin or one database, and the report names which. Declare .lifecycle(SectionLifecycle.LAZY) for cold data you do not want on that path.


A write on a released section instance is a LOST WRITE (and now says so)

The flush persists the cached values. Holding a section reference across ticks and writing to it after the cache released the cell - idle release, a cache TTL, the maxCached ceiling, a plugin re-registration - means the flush never sees that write. Since 3.0.1 this is reported: markDirty() on such an instance logs LOST WRITE naming the section, the key and the likely cause, once per pair. It is logged, never thrown. Re-resolve the section where you use it; for an online player that is an already-completed future.


Where player data lives is the admin's decision

A PDSection does not choose its own database. It is persisted on whichever backend the server admin configured in storage.yml (MySQL/MariaDB, PostgreSQL, H2, MongoDB, files, or in-memory). Your plugin code is backend-agnostic by design - do not assume a particular engine, a SQL dialect, or that two sections even share a backend. See Storage Backends and, for routing a plugin's own data, Inline Backends for Plugins.

On Hytale, MySQL/MariaDB is not available (the GPL driver is excluded from the shadow jar and there is no libby to fetch it). Use PostgreSQL/H2/MongoDB/files there. See Hytale Platform.


Restart the server - don't /reload

Install and update EverNifeCore with a full server restart, not Bukkit's /reload. As a framework that owns long-lived state (storage backends, player-data caches, registered commands and listeners), it - like most non-trivial plugins - is not designed to survive /reload. Use stop and start again.


Platform types and argument parsers register lazily, on first use

Two bootstrap hooks fire the first time the subsystem that needs them initializes, not eagerly at enable:

  • Config types (registerConfigTypes()) register when ConfigFactory first initializes - before any config is opened.
  • Argument parsers (registerArgParsers()) register when FinalCMDManager first initializes - the first time a command is registered.

This lazy timing is deliberate: it lets a plugin that loads before EverNifeCore still get the platform's types and parsers when it first uses them. The practical takeaway is that these are available by the time you register a command or open a config - which is the only time you need them. See Argument Parsing and Configuration.


First boot after upgrading runs a one-time legacy import

The first time a server starts on the new storage layer, EverNifeCore imports any legacy YAML player data and consolidates it. This is a one-time migration; on a large server, run that first boot in a maintenance window rather than at peak. Subsequent boots skip it. See Legacy Data Migration.


An unreachable storage backend stops the server at boot

A backend declared enabled: true in storage.yml that does not answer at boot makes the server stop, with a report naming every unreachable one. It only happens on a boot: a failed reload never stops anything, because the previously loaded storage is still live. The switch is Settings.Storage.STOP_SERVER_IF_STORAGE_IS_UNREACHABLE in plugins/EverNifeCore/config.yml, default true - and turning it off does not make the server run without a database, it only trades a clean stop for EverNifeCore staying disabled. See Storage Backends.


Two file backends in one directory stop the boot - and that is the mild outcome

Two enabled file backends (groupedfile/localfile) whose path resolves to the same directory, or one whose directory sits inside the other's, cancel the boot with a report naming both and the absolute path each resolved to. It costs one edited path to fix, and it is fatal rather than a warning because everything it prevents is silent:

  • a groupedfile's per-key lock lives in a store per Storage instance, so two backends over one directory are two independent lock maps over the same files. Mutual exclusion stops existing and two concurrent writes to the same key overwrite each other with no error at all;
  • each backend lists every file in the directory carrying the resolved extension, so each starts trying to decode the other's files as its own.

A warning in a boot log nobody reads would not protect against that, and the data would already be corrupt by the time someone noticed. A disabled backend is exempt (it opens nothing), and an h2 file sitting in such a directory is deliberately allowed: it does not match the extension a file backend lists, so it is neither read nor overwritten. See Storage Backends.


network.storage-backend-id is required, and never inherits

The block that routes the account family and the network cooldowns names its backend explicitly. Absent, empty, naming an undeclared backend or a disabled one - each cancels the boot. There is no "empty means default-backend", on purpose: that implicit fallback is what would let an unrelated edit to default-backend silently migrate the whole network family to another database. A storage.yml still carrying the old multi-platform-accounts block is refused outright, with a message mapping each of its keys onto the replacement. See Storage Backends.


A placeholder key is declared BARE - with delimiters it never matches

addPlaceholder("amount", 50) declares the key; ${amount} is how the text cites it. A key written with its delimiters ("%amount%", "${amount}", "{amount}") is registered exactly like that, so it never matches anything. It is not silent - the registration warns once per key, naming the bare form - but the warning is in the console, not at the call site. This is the same rule for a Localization message, a raw FancyText and a PageViewer line.

Related: two keys of the same message differing only in case (amount and Amount) throw at registration. The alternative would be one silently shadowing the other, with the winner depending on registration order.


Hover does not exist on Hytale

The Hytale server API has no hover, so a message rendered there drops its tooltip. A hover kind that declares a degrade falls back to it once; one that does not simply renders without a hover. Nothing throws, and the same code keeps working on Bukkit - but do not build a UI whose only affordance is a tooltip if the plugin is meant to run on both. See FancyText.


A message sent from an async task loses ${label} unless you pass the context

${label} and ${subcmd} come from the command scope of the sending thread. A message delivered later, from a scheduled or asynchronous task, has no such scope and renders them empty. Build the context while you still have it and hand it to the send:

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

Each recipient still gets their own render; what the explicit context contributes is the command scope, not the recipient.


Upgrading a command: the language file keeps the old entries, and they do nothing

A help-line entry is filed under the command path (lp.user.permission.SET) and a static LocaleMessage field under the chain of enclosing simple names. Anything filed under the old scheme - or under a path you have since changed - is not read, is not deleted, and is not reported: the new key is simply created next to it with the annotation's default text.

This is the one upgrade step that bites someone who never recompiles, only updates the jar. After a version that moves keys, diff the language file and move your translated text onto the new keys; the orphans are then safe to delete.


Disabling a command moved to an opt-in file, and a disabled parent always wins

The single commands.yml that used to gate every command of every plugin is no longer read (it is not migrated and not deleted either). Its replacement is off by default: set Settings.Commands.REGISTRY_FILES_ENABLED: true and rewrite the entries under plugins/EverNifeCore/commands/<PluginName>.yml, nested like the tree. A server that disabled a command through the old file gets that command back on the first boot after the upgrade.

Inside the new file, a branch with enabled: false takes its whole subtree with it, and a child that says enabled: true underneath it does not come back - the parent wins, because a revived child would sit at the end of a path nobody can type. The console only reports how many entries changed; turn on the COMMAND_REGISTRY debug module to see which ones, and why each was ignored.


argumentos.get(0) is the first token after the PATH, not after the label

The MultiArgumentos a method receives is the window that executable owns. For a leaf under a node, /mycmd user Steve set a b, getStringArg(0) is a - the label, the literals and the captured tokens are all already gone. The same is true of ArgInfo.getIndex() inside a parser.

This one compiles fine and reads the wrong token, which is the worst kind. Grep your commands for argumentos.get before assuming an upgrade was source-compatible.


Tab-complete answers nothing rather than answering wrong

Where the framework cannot say what a position holds, the answer is an empty list. There is no fallback to the platform's "list the online players", because that reply looks exactly like a correct one: a player learns to trust a tab that suggests a player name where a backend id was expected.

And what tab hands your parser is the raw token an ancestor captured - TabContext.getCaptureToken("user") - never a resolved value. Tab runs once per keystroke; resolving a player, a region or a database row on every one of them is a cost the traversal never needed. If you want the object, resolve it yourself, with your own cache.


@Arg(fromSender = true) only fires when its own position is empty

An inferred argument does not hand the token back to the argument after it. In practice it has to be the last positional (or optional): put one in the middle and the tokens after it shift onto the wrong parameters. The parser also has to override fromSender(ParseCall) - one that does not is refused at registration rather than silently eating a token.


A contextual parameter resolves BEFORE the tokens, and that null is the contract

A contextual parameter runs before the tokens by default (ResolutionPhase.BEFORE_ARGUMENTS), and that is what lets a positional's parser read what the invocation produced. The price is symmetric: inside such a parser, previouslyParsed(...) for anything a token or a flag produced answers null - those have not run yet. It is a contract, not a missing value, so there is nothing to null-check your way out of.

An ancestor's capture is the exception, and not an accident: it was resolved while the path was being walked, before any parameter of the method, so it is already in the bag when the first contextual runs.

If your parser needs a typed value, say so once, on the parser:

@Override
public ResolutionPhase defaultPhase() {
    return ResolutionPhase.AFTER_ARGUMENTS;
}

A single parameter can override it either way with @Arg.Contextual(value = "...", phase = ...), and the annotation always wins. Answering PARSER_DEFAULT from defaultPhase() is refused at registration - that value is the question, not an answer to it.


A contextual parser that does I/O should declare AFTER_ARGUMENTS

The early phase runs for every dispatch of the method, before the framework knows whether the tokens are even parseable. A parser that hits a database, a web service or a world thread on the early phase pays that cost on every mistyped command - and then the dispatch aborts on a bad token anyway.

Declaring AFTER_ARGUMENTS puts the expensive parameter behind the cheap refusals: the positionals have already parsed (or already aborted) by the time it runs. Keep the early default only for what is genuinely free - the sender, the label, the path, the help line.


For contributors: the Java 8 API floor bites in the dual-target modules

common, minecraft, api-contracts, and libby compile to Java 8 bytecode with a Java 8 API floor (options.release = 8). Modern syntax is allowed (via Jabel), but a Java 9+ library API

  • List.of, Optional.isEmpty, String.strip, ... - will not compile there, and record/sealed are unavailable.

The four version-compat modules (minecraft:modules:*) are the opposite trap: they have no API floor at all (they can't use options.release because they need sun.misc.Unsafe). A Java 9+ API compiles cleanly there and then fails at runtime on a 1.7.10 server - only code review catches it. hytale is plain Java 25 with no cap. See Java Versions and Toolchains.


See also

Clone this wiki locally