-
Notifications
You must be signed in to change notification settings - Fork 7
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.
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.
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(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)- asyncOptional, empty on a true miss, seeds nothing. -
hasPDSection(cls)- asyncBoolean; a transient default counts as absent (only a genuinely stored row returnstrue).
Reach for getPDSectionIfPresent / hasPDSection when "has this player ever had this data?" is the
actual question. See PlayerData and PDSections.
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.
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.
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.
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.
Two bootstrap hooks fire the first time the subsystem that needs them initializes, not eagerly at enable:
-
Config types (
registerConfigTypes()) register whenConfigFactoryfirst initializes - before any config is opened. -
Argument parsers (
registerArgParsers()) register whenFinalCMDManagerfirst 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.
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.
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 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.
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.
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.
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.
${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.
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.
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.
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.
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.
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 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.
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.
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, andrecord/sealedare 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.
- PlayerData and PDSections - the async section model in full.
- Storage Backends - where data lives and how the admin picks a backend.
- Command Framework - the tree, the five dispatch phases, and what each shape error refuses.
-
Argument Parsing - the window,
fromSender, the variadic tail, the fourArgParsermethods, and theResolutionPhasethat decides when a contextual parameter runs. - Architecture Overview - startup order and the two abstraction seams.
- Java Versions and Toolchains - the bytecode/API-floor rules.
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Scheduler & Threading
- Items & NBT
- GUI Framework
- Integrations
- Economy
- Version Compatibility
Architecture & Reference