-
Notifications
You must be signed in to change notification settings - Fork 7
PlayerData and PDSections
Per-player data in EverNifeCore is split into a small base entity (PlayerData) plus any number of
pluggable sections (PDSection) that plugins register. Each section is an independent entity,
persisted on the backend the admin picked in storage.yml, keyed by the player's platform UUID.
The storage engine underneath is EveryDatabase - this page is the EverNifeCore-side layer over it. Where the admin chooses where the rows live is on Storage Backends; account-shared data is on Accounts.
A PDSection is a plain object that Jackson (de)serialises by
field access. The contract for a subclass:
- Declare a no-arg constructor (Jackson decodes through it).
- Persisted fields are picked up directly by Jackson - getters are never serialised.
- Runtime-only fields are marked
@JsonIgnore. - Persistence is automatic on the flush tick (about every 30 s, jittered) once you call
markDirty().
import br.com.finalcraft.evernifecore.playerdata.PDSection;
public class JobsSection extends PDSection {
public int level; // persisted (Jackson reads the field)
public String job = "none"; // persisted; a field default is the "new player" value
public void levelUp() {
level++;
markDirty(); // the ONLY way to schedule a save
}
}markDirty() is the whole persistence API: mutate the fields, mark the section dirty, and the flush
pipeline writes it out. A section that is never dirtied is never written.
📌 Hard size rule - large data does not belong in a PDSection. A section is meant to hold the id of a robust entity (a guild, an inventory blob, an analytics row) that lives in its own collection. A section that never releases its cells (
RESIDENT/PRELOADED) and grows past 100,000 cached cells logs a DEBUG warning (it never cuts).
Register the section's configuration once, at plugin enable, through PlayerController. The
section id is required: it is the stable storage identity, so you can rename the class whenever
you like without moving a single row.
import br.com.finalcraft.evernifecore.playerdata.PlayerController;
// ecPluginData is your plugin's metadata handle (IECPlugin#getPluginData()).
// you can get by passing your JavaPlugin (Bukkit or Hytale) like this
ECPluginData ecPluginData = ECPluginManager.getOrCreateECorePluginData(yourPluginInstance);
PlayerController.registerPDSectionCfg(ecPluginData, JobsSection.class, "jobs");
// on the database the collection will be 'pd_yourplugin_jobs'That id must match [a-zA-Z0-9_]{1,32} (it is lowercased for you) and be unique within your plugin;
anything else is rejected at registration rather than quietly sanitised. Together with your plugin
name it produces the collection (pd_myplugin_jobs), the pdsections.myplugin.jobs entry in
storage.yml and the id admin commands take (/ecstorage transfer section myplugin:jobs <backend>).
For anything beyond the defaults, build a PDSectionConfiguration:
import br.com.finalcraft.evernifecore.playerdata.PDSectionConfiguration;
import br.com.finalcraft.evernifecore.playerdata.storage.SectionLifecycle;
PlayerController.registerPDSectionCfg(
PDSectionConfiguration.builder(ecPluginData, JobsSection.class, "jobs")
.lifecycle(SectionLifecycle.ONLINE) // when cells enter/leave memory (see below)
.defaultBackend("playerdata") // advises; the admin decides in storage.yml
.description("Job level and progress") // documents the generated storage.yml entry
.build());The developer advises (default backend, lifecycle, suggested backends); the admin decides
through storage.yml. The one hard constraint the developer can impose is
allowedBackendTypes(BackendType...) - e.g. an ephemeral section that must only ever live on an
in-memory backend, never a database.
Plugins usually register from their own config initialisation, which also runs on their reload. A second registration of an already-bound class is therefore treated as a reload: the section's dirty cells are flushed, its cache is dropped, and the binding is rebuilt from the fresh configuration (so a changed backend or lifecycle actually takes effect). The previous session's in-memory state never survives - a derived value your reload re-applies cannot be counted twice.
If a section's in-memory state is genuinely derived and must not be written back on a reload,
declare .discardDirtyOnReload(); the default flushes first, because the flush window is about 30
seconds and dropping it would cost every online player whatever they earned since the last tick.
When a plugin is disabled at runtime, call PlayerController.unregisterPDSections(ecPluginData) so the
registry stops holding the plugin's classes and its collection claim is released.
Resolution is 100% async - every accessor returns a CompletableFuture. On the hot path (an online
player, section already cached) the future is already completed, so .join() is a cache hit.
// The canonical read: resolve (or transient-default-seed) this player's section.
PlayerData player = PlayerController.getLoaded(uuid);
player.getPDSection(JobsSection.class).thenAccept(jobs -> {
jobs.levelUp();
});
// Static shortcut that lazy-loads the player itself when needed:
PlayerController.getPDSection(uuid, JobsSection.class); // CompletableFuture<JobsSection>getPDSection(...) never completes with null for a known player: when the backend has nothing yet it
seeds a transient default - a fresh instance living only in the cache, with no write. It is
persisted only once you markDirty() it. This is what lets "new player" defaults (field initialisers)
work without an I/O round-trip.
Because a transient default is cache-only and unsaved, the presence primitives treat it as absent:
| Accessor | Returns | Seeds a default? | Notes |
|---|---|---|---|
getPDSection(cls) |
CompletableFuture<T> |
✅ | the canonical read; per-online-player |
getPDSectionIfPresent(cls) |
CompletableFuture<Optional<T>> |
❌ | bulk-safe "does it exist?" read |
hasPDSection(cls) |
CompletableFuture<Boolean> |
❌ | a cache-only transient default counts as absent |
getPDSectionIfLoaded(cls) |
T or null
|
❌ | sync, cache-only, never touches storage |
hasPDSectionIfLoaded(cls) |
boolean |
❌ | sync peek: false means "not loaded", not "absent" |
⚠️ Do not loopgetPDSectionover the whole player base for an aggregate - it seeds a default per key. UsegetPDSectionIfPresent(...)for a bulk-safe presence read, or the controller's indexedquerySection(...)API for a real aggregate.
To force a flush of a single section without waiting for the tick: section.forceSavePDSection() (that
section only) or section.forceSavePlayerData() (the whole player).
A section declares one SectionLifecycle, which answers the only two questions there are: when
a cell enters memory, and when it leaves.
| Lifecycle | Enters | Leaves | Use for |
|---|---|---|---|
ONLINE (default)
|
the player's login (the async pre-login is held until it completes) | an idle grace after the owner goes offline | the normal case: anything the player's session actually uses |
LAZY |
the first effective access | an idle grace after the owner goes offline | cold data most players never touch, or that only an admin command reads |
RESIDENT |
the player's login | never | a small section constantly read for players who are offline |
PRELOADED |
the whole collection, at bind time | never | an aggregate that scans everything anyway |
The ONLINE default is a guarantee, and that is the point. The login pipeline runs inside the
platform's async pre-login event and holds the connection until every ONLINE section has been
resolved. So by the time the player is in the world, the cell is already in memory and every later
getPDSection completes from cache - your code never has to plan around a section arriving late.
The bound on that wait is playerdata.login-timeout-seconds (15s): if storage does not answer in
time the login is denied, never served with data missing.
The price is one read per section at every login. LAZY is how you opt out for cold data; it runs
exactly the same resolution, just later. The admin can override your choice per section with
pdsections.<plugin>.<id>.lifecycle - what a server can afford on the login path is their call, not
yours.
When a login crosses playerdata.slow-login-report-seconds (3s), the console gets a breakdown of it:
each section's load time, which plugin declared it, its author, and the backend it sits on. That is
what tells an admin whether a slow login is EverNifeCore, one specific plugin, or one specific
database - and it prints on a timeout too, where the sections still loading are the evidence.
Two optional knobs go with it: idleGrace(Duration) and maxCached(int) (a hard LRU ceiling; a dirty
cell is never dropped by it). The idle grace resolves as admin per-section
(pdsections.<plugin>.<id>.idle-grace-seconds) > your idleGrace(...) > admin default
(playerdata.default-idle-grace-seconds) > one hour - so leave it unset unless this section has a
reason to differ, and the admin still gets the last word either way. An admin cache: override in storage.yml sets freshness only - the lifecycle stays yours.
cache.policy: NOCACHE is refused for a PDSection: the cached cell is the instance the flush
pipeline persists, so bypassing the cache would lose every write.
⚠️ Do not hold a section reference across ticks. Once a cell is released the instance you kept is no longer the cached one, and amarkDirty()on it is invisible to the flush. Re-resolve the section where you need it - for an online player that is a completed future.This no longer fails quietly:
markDirty()on an instance the cache has released logs aLOST WRITEnaming the section, the key and the likely cause (idle release, cache TTL, themaxCachedceiling, a plugin re-registration orclearPDSections). It is reported, never thrown - breaking a plugin mid-tick would be worse than the write it is warning you about.
A bulk read (an admin sweep, a one-off report) leaves cells behind for players who are not online. They are released by themselves after the idle grace, but you can force it:
PlayerController.releasePDSection(JobsSection.class); // CompletableFuture<Integer> cells releasedIt flushes the dirty cells and evicts only the ones whose owner is offline - an online player's cell
has to stay canonical. For an actual aggregate prefer PlayerController.querySection(...), an indexed
backend query that never populates the cache to begin with.
Every UUID resolves through the account layer, on every boot - there is no switch for it. That is not a
behaviour change to plan around: a UUID that was never linked resolves to a singleton account whose
accountId == uuid, so an account-scoped key is byte-for-byte the key plain UUID keying would have
produced. Until an admin runs /ecaccount link, ec_accounts stays empty and nothing about your
sections changes.
A PDSection is keyed by the player's UUID either way - the account key only applies to an
AccountSection. See Accounts.
When two writers race the same row, EveryDatabase's optimistic lock lets one win. EverNifeCore resolves
the conflict with a single, visible policy: ADOPT_WINNER (first-wins). The stored winning state is
re-adopted into your live instance - plugins keep their references; the losing local changes are
discarded rather than silently merged. If you still want your change, re-apply it and markDirty()
again. (Account sections converge differently - they merge; see Accounts.)
The write-back and conflict model is EveryDatabase's - Write-Back & Conflict Resolution has the full picture.
PlayerController.deletePlayerData(uuid); // CompletableFuture<Void>Deletes the base PlayerData and every registered section row for that player (cascade), evicting
each cell from its cache. Rules that matter:
- Offline only - deleting an online player throws (their live references would resurrect the rows).
- The base row is deleted last - a failed section delete fails the whole operation before the base is gone, so the base always survives as the anchor a retry (or the reaper) keys off.
-
Account sections cascade by identity. For a singleton account (not yet linked) the
account-wide rows are keyed by the uuid and are deleted with it. For a linked member the shared
canonical row (keyed by the account's canonical
accountId) is kept - it belongs to the other identities too - and you do not need to unlink first: the delete also drops that member's own former-key row (the one under its own uuid, written as a singleton before the link and never absorbed because the member never logged in again), so it cannot leak. The reaper never sweeps account sections, which is exactly why this former-key row has to be dropped here rather than later.
An optional, opt-in orphan reaper (playerdata.orphan-reaper.enabled in storage.yml, off by
default, default interval 360 min) periodically sweeps PDSection rows whose base no longer exists - the
leftovers of an out-of-band base delete. Account sections are never swept (their rows belong to an
account, not one base).
Every stored section carries an on-disk schemaVersion. When you change a section's shape, register a
migration chain so old rows upcast lazily on read:
PDSectionConfiguration.builder(ecPluginData, JobsSection.class)
// upgrade a payload written at v1 into v2 (a file-less, type-aware ConfigSection)
.migration(1, section -> section.setValue("job", section.getString("profession", "none")))
.build();Steps form a contiguous chain from the initial version; adding one bumps the section's current version. A step runs on the raw payload before binding (no legacy fields on your POJO). This wraps EveryDatabase's EntitySchema layer - the concepts and the eager-sweep option are documented on Payload Schema Evolution and Schema Migrations.
- Accounts - account-wide sections shared across linked identities.
-
Storage Backends - where
storage.ymlsends PlayerData and each section. - Cooldowns - built on per-player and account sections.
- Configuration - the YAML config layer and its shared type authority (a different concern).
-
Legacy Data Migration - importing pre-3.0
PlayerData/*.ymlfiles.
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