Skip to content

Accounts

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

Accounts

The account layer links platform identities that belong to the same human - Minecraft ⇄ Hytale ⇄ external providers (Discord, a website) - into one account, and lets a plugin store data that is shared by every linked identity.

It is the counterpart to PlayerData & PDSections: a PDSection belongs to one player (keyed by platform UUID); an AccountSection belongs to the account (keyed by a canonical accountId).


The canonical accountId

Every platform UUID resolves to an Account. A UUID that has never been linked resolves to a singleton account whose accountId == uuid. So until a link actually happens, an account-scoped section keys by exactly the same value plain UUID keying would - account scoping only changes behavior once identities are linked.

The first real link mints a brand-new random accountId (never a member's UUID), persists the canonical account row, and writes one alias row per identity so every member stays resolvable by its own key. accountIds are opaque - integrations must not derive meaning from them.

import br.com.finalcraft.evernifecore.playerdata.account.Accounts;

// Async resolve of the stored account (NEVER null - an unlinked uuid gets its singleton account):
Accounts.get().account(playerUuid).thenAccept(account -> {
    UUID accountId = account.getAccountId();
    account.getMembers().forEach(m -> { /* provider, providerUid, name */ });
});

boolean layerUp = Accounts.isEnabled();   // has the layer bootstrapped yet?

PlayerData#getAccountId() gives the same canonical id for an already-resolved player.


There is nothing to enable

The layer bootstraps on every boot; there is no switch for it in storage.yml. A flag would have had nothing to control: without a link, every identity is its own singleton account and keys exactly as plain UUID keying would - so "on with no links" and "off" were the same server. Linking is not a setting, it is an operation: an account exists once an admin runs /ecaccount link.

Accounts.isEnabled() survives with the narrower meaning it always had in practice: whether the layer has bootstrapped. It is false before the storage boot and in tests, where Accounts.get() returns a no-op facade resolving each UUID to its own singleton (mutating operations on that facade fail with a clear error). It is not an admin's answer to anything.

What is configured is where the family lives - the one backend under network.storage-backend-id, shared with the network cooldowns:

network:
  storage-backend-id: networkdata   # REQUIRED - no implicit fallback
  # idle-grace-seconds: 3600        # absent = follow playerdata.default-idle-grace-seconds

⚠️ On a real network that backend must be a database shared by every instance (MariaDB/MongoDB/PostgreSQL/…), never a local-file backend - the account family lives on that one backend and every server has to agree on it. This is a separate concern from multi-server cache-sync (same platform, shared DB); accounts is about linking different identities and is meaningful even on a single server (e.g. linking Discord).

Note what the backend alone already buys you: two Minecraft servers pointed at one shared database share account data with no link at all, because they already agree on a player's UUID. Linking is what you need when the UUID itself differs - a Hytale server, a Discord identity. Moving the family to that shared database later is one command: /ecstorage transfer network.


Account sections

An AccountSection<T> is a section owned by the account rather than a player. It carries no player reference, and two linked players online at once share the same live instance. Because it can be written from several servers, consistency is eventual and convergence is driven by a merge(...) method you implement.

import br.com.finalcraft.evernifecore.playerdata.AccountSection;
import java.util.List;

public class NetworkStatsSection extends AccountSection<NetworkStatsSection> {
    public long globalKills;

    /**
     * Pure, associative, commutative. Return a NEW instance; never mutate this or the inputs.
     * The framework tracks what was already merged, so it need NOT be idempotent.
     */
    @Override
    public NetworkStatsSection merge(List<NetworkStatsSection> others) {
        NetworkStatsSection result = new NetworkStatsSection();
        result.globalKills = this.globalKills;
        for (NetworkStatsSection other : others) result.globalKills += other.globalKills;
        return result;
    }
}

merge is called both when linked identities' rows are coalesced and when a concurrent-write conflict is resolved - it is the section's convergence policy. Contrast this with a PDSection, which resolves conflicts by ADOPT_WINNER (first-wins) rather than merging.

Register and read it through PlayerController:

// "netstats" is the stable section id, same contract as a PDSection's
PlayerController.registerAccountSectionCfg(ecPluginData, NetworkStatsSection.class, "netstats");
// on the database the collection will be 'acs_yourplugin_netstats'

// keyed by the player's canonical accountId:
PlayerController.getAccountSection(playerUuid, NetworkStatsSection.class);          // CompletableFuture<T>
PlayerController.getAccountSectionByAccountId(accountId, NetworkStatsSection.class);// CompletableFuture<T>
NetworkStatsSection cached = PlayerController.getLoadedAccountSection(uuid, NetworkStatsSection.class);

The whole account family lives on the ONE network backend (no per-section routing), the cache lifecycle is fixed (resident while any member is online, released once none is - after the last member quits, or after network.idle-grace-seconds of nobody using a row that was read for an offline account), and the row is always loaded at a member's login - so AccountSectionConfiguration is deliberately smaller than a PDSectionConfiguration (collection name, description(), discardDirtyOnReload() and schema migrations).

The admin side is smaller too, and lives under accountsections in storage.yml: the collection, and a cache policy. Reach for cache: { policy: TTL, ttlSeconds: N } on a network without Redis - an account row is written by every instance, and a TTL is what bounds how long this server may keep serving a version another server has already moved past.

Re-registering the class reloads it exactly like a PDSection: the rows are flushed, the cache is dropped and the binding is rebuilt. discardDirtyOnReload() skips that flush - weigh it harder here than on a PDSection, because an account row is shared by every linked identity and written from the whole network, so what you discard may not even be this server's write.


The /ecaccount command

Permission node evernifecore.command.account; the mutating subcommands additionally require evernifecore.command.account.link.

Subcommand Usage What it does
info /ecaccount info <player> Shows the stored account a player belongs to and its linked members.
link /ecaccount link <target> <source> Links two identities into one account. Data follows at each member's next login.
unlink /ecaccount unlink <player> Removes a member: it stands alone and starts fresh next login; the account keeps the shared data.
migrate /ecaccount migrate <player> Forces the account-data reconciliation of an offline player (it runs at login otherwise).

Implications worth stating plainly:

  • Link is identity-only; data follows lazily. A link moves identity immediately but the account-wide data a member accumulated as a singleton (under its own UUID) is absorbed into the canonical rows at that member's next login, or eagerly via /ecaccount migrate. The absorption is recorded in a per-row ledger, so an interrupted or repeated migration never double-applies a non-idempotent merge.
  • Merging accounts is transitive. Linking two already-populated accounts unions their members; the ledger keeps convergence correct.
  • Unlink is a fresh start for the member, not a data transfer - the account retains the shared row.

External identities

Beyond platform UUIDs, an identity from an external provider can be linked from code:

Accounts.get().linkExternal(playerUuid, "discord", discordUserId);
Accounts.get().findByExternal("discord", discordUserId);   // CompletableFuture<Optional<Account>>
Accounts.get().unlinkExternal("discord", discordUserId);

See also

Clone this wiki locally