-
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 - that is what makes the default
residentcache safe. Aresidentmanager that grows past 100,000 cached cells logs a DEBUG warning (it never cuts).
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.
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).
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).
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
Loadable/Salvablelayer (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