Skip to content

PlayerData and PDSections

Petrus Pradella edited this page Jul 23, 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 - that is what makes the default resident cache safe. A resident manager that 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:

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

For anything beyond the defaults, build a PDSectionConfiguration:

import br.com.finalcraft.evernifecore.playerdata.PDSectionConfiguration;
import br.com.finalcraft.evernifecore.playerdata.storage.SectionCachePolicy;

PlayerController.registerPDSectionCfg(
    PDSectionConfiguration.builder(ecPluginData, JobsSection.class)
        .collection("myplugin_jobs")           // default is derived from the class name and your plugin name
        .defaultBackend("groupedfile")         // advises; the admin decides in storage.yml
        .cache(SectionCachePolicy.workingSet())// cache lifecycle (see below)
        .build());

The developer advises (default backend, cache policy, 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.

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 policies

Each section declares a cache lifecycle through PDSectionConfiguration.cache(...), a SectionCachePolicy. It maps onto an EveryDatabase cache policy plus the framework-only behaviors the store does not model (evict-on-quit, timer-driven purge).

Policy Freshness / capacity Use for
resident() (default) always(), unbounded - the loaded set stays cached small, id-only sections
lru(maxSize) always() + bounded LRU (keeps the hottest maxSize) a section that spans more players than fit in memory
ttl(Duration) TTL freshness + a scheduled purgeExpired() data another process may write; tolerate bounded staleness
workingSet() always(), unbounded, but a player's cell is evicted a short grace (default 60 s) after they quit resident-while-online data

An admin cache: override in storage.yml still wins over the developer's choice. Warmup is separate: warmup(SectionCachePolicy.Warmup.ALL) pre-loads the whole collection at bind time (default is lazy).


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

Clone this wiki locally