Skip to content

PlayerData and PDSections

Petrus Pradella edited this page Jul 31, 2026 · 7 revisions

PlayerData & 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 Jackson POJO

A PDSection is a plain object that Jackson (de)serialises by field access. The contract for a subclass:

  1. Declare a no-arg constructor (Jackson decodes through it).
  2. Persisted fields are picked up directly by Jackson - getters are never serialised.
  3. Runtime-only fields are marked @JsonIgnore.
  4. 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).


Registering a section

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.

Registering again reloads the section

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.


Reading a section

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>

The transient default

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 loop getPDSection over the whole player base for an aggregate - it seeds a default per key. Use getPDSectionIfPresent(...) for a bulk-safe presence read, or the controller's indexed querySection(...) 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).


Cache lifecycle

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 a markDirty() 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 a LOST WRITE naming the section, the key and the likely cause (idle release, cache TTL, the maxCached ceiling, a plugin re-registration or clearPDSections). It is reported, never thrown - breaking a plugin mid-tick would be worse than the write it is warning you about.

Freeing memory on purpose

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 released

It 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.


Account keying is always on, and costs you nothing

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.


Conflict resolution: ADOPT_WINNER

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.


Deleting a player (cascade)

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).


Schema versioning

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.


See also

  • Accounts - account-wide sections shared across linked identities.
  • Storage Backends - where storage.yml sends 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/*.yml files.

Clone this wiki locally