Skip to content

Cooldowns

Petrus Pradella edited this page Jul 30, 2026 · 3 revisions

Cooldowns

A cooldown in EverNifeCore is a small timed gate: it remembers when something happened and answers "has enough time passed?". The system is a 2×2 matrix - a cooldown belongs either to the server or to a player, and its reach is either local (this server only) or network (shared across a network on the shared backend).

                   LOCAL (this server)          NETWORK (shared across the network)
  SERVER-wide      Cooldown.of(id)              Cooldown.network(id)
  PLAYER-owned     PlayerCooldown.of(uuid, id)  PlayerCooldown.network(uuid, id)

Every handle is a Cooldown (or the PlayerCooldown subclass). The handle is only a view over a CooldownEntry; where that entry lives (a config file, a PDSection row, an account row, the shared server-cooldown collection) is what the four factories choose for you.


The four factories

Server, local - Cooldown.of(String)

Server-wide, this-server-only. Stored in this server's Cooldowns.yml under AllCooldowns.<id> (only while persistent). Synchronous.

Cooldown eventGate = Cooldown.of("weekly_event");
if (eventGate.isInCooldown()) {
    eventGate.warnPlayer(sender);          // "you need to wait more ..."
} else {
    eventGate.startWith(7 * 24 * 60 * 60); // start a 7-day cooldown (seconds)
    eventGate.setPersist(true);            // survive a restart
}

Server, network - Cooldown.network(String)

Server-wide and network-wide: a handle over a row in the shared ec_server_cooldowns collection on the account backend, so every instance reads and writes the same state. Synchronous (the collection is warm).

When the network storage is not bootstrapped - a single server with no shared backend - it collapses to Cooldown.of(id): one server is the whole network, so a local cooldown already has network reach.

Cooldown global = Cooldown.network("global_boss");
global.startWith(300);            // that's it - the handle is born persistent, so this replicates
global.setPersist(true);          // redundant now (born persistent), but harmless if you keep it

📌 A server network handle is born persistent. Like PlayerCooldown.network(...), the handle from Cooldown.network(id) starts already persistent - a network cooldown only means anything if it replicates, so the row reaches storage the moment the cooldown is actually started (never on a bare read). You do not need to call setPersist(true) on it; the shipped /eccooldown set ... --network command still does, purely for uniformity with the local-cooldown path, and it stays harmless.

Player, local - PlayerCooldown.of(UUID, String)

Owned by one player, this server only: a handle over the player's PlayerCooldownsLocal PDSection row. Async, because the row may have to be read from the backend on a cache miss; for an online player the row is hot-loaded at login, so .join() is a cache hit and safe on the main thread.

PlayerCooldown.of(uuid, "kit_daily").thenAccept(cd -> {
    if (!cd.isInCooldown()) {
        cd.startWith(24 * 60 * 60).setPersist(true);
    }
});

Like the server-local one, not persistent by default - call setPersist(true) to outlive a restart.

Player, network - PlayerCooldown.network(UUID, String)

Owned by the player's account and seen by every instance: a handle over the account-shared PlayerCooldownsNetwork account section row. Async, same as above, and born persistent - a network cooldown only means anything if it replicates, so the row grows the moment it is actually started (never on a bare read).

From a PlayerData - getCooldown(String)

IPlayerData#getCooldown(identifier) is the convenience shortcut for the player-local route:

playerData.getCooldown("home").thenAccept(cd -> { /* ... */ });   // == PlayerCooldown.of(uuid, "home")

For an offline target, bridge the async result back with PlayerController.whenCompleteOnMainThread(future, callback) instead of blocking.


The handle API

Common operations on any Cooldown:

Method Effect
startWith(seconds) / startWith(value, TimeUnit) set the duration and start now
start() (re)anchor the start at now, keeping the current duration
stop() clear the cooldown (see semantics below)
setDuration(millis) change the nominal duration
setPersist(boolean) whether it outlives the process / reaches storage
isInCooldown() true while time is still left on the stored duration
isInCooldown(customWaitSeconds) ask against a different duration (e.g. a VIP early-access window)
getTimeLeft() / getFCTimeFrame() remaining time (millis / formatted)
warnPlayer(sender) send the localized "you must wait N" message

stop() semantics

stop() is a tombstone, not a delete of nothing: it sets the start anchor to 0, clears the persist flag, and stamps the mutation clock. If there was a stored row, the route is still told (to drop it) - and on a network route the tombstone itself is written so peers see the stop rather than resurrecting the old state.

The start is an anchor, not a deadline

CooldownEntry.timeStart is an absolute epoch anchor, and that is deliberate: a read can reinterpret the same anchor against a duration of its own. Asking isInCooldown(150) on a cooldown that was started for 300 s lets you, say, wave a VIP through at the halfway mark - something an entry that only stored the expiry could never answer.


Persistence & replication

Route Lives in Written Convergence
Cooldown.of this server's Cooldowns.yml on mutation (async save) none (single file)
Cooldown.network ec_server_cooldowns on the shared network backend immediately on mutation last-write-wins merge
PlayerCooldown.of PlayerCooldownsLocal PDSection (loaded at login) on the flush tick, once dirtied ADOPT_WINNER (per-player)
PlayerCooldown.network PlayerCooldownsNetwork account section on the flush tick, once dirtied last-write-wins merge

The server-network collection is the admin's to rename. network.server-cooldowns.collection in storage.yml is generated on first boot and exists so a name collision with another plugin has a way out; cache: { policy: TTL, ttlSeconds: N } next to it bounds how stale another server's write may look here when the backend has no change feed. NOCACHE is refused - the flush iterates the cached values, so a route without a cache would lose every write. See Storage Backends.

Last-write-wins convergence. Two servers mutating the same cooldown converge through CooldownEntry.latest(a, b): newest updatedAt wins, with fixed tiebreakers (expiry, then start, then persist) so latest(a, b) and latest(b, a) always settle on the same state. It is deliberately not a max over expiry - that would resurrect a cooldown a newer stop() has already tombstoned.

Read-through / freshness (network routes). A cache miss is never answered as "no cooldown". The server-network route reads through to the backend first: a cell the cache-sync marked stale (because a peer just wrote that very cooldown) misses exactly like a never-seen id, and answering "free" there would be the cross-server bypass the route exists to prevent. So a miss costs a single point read, only for an id no row exists for yet or one a peer just wrote.


Admin command: /eccooldown

Aliases /eccooldown, /eccooldowns. Covers every quadrant of the matrix - set/setplayer take the network reach through a --network/-n flag instead of a separate subcommand:

Subcommand Reach
viewserver show a server (local) cooldown
viewplayer <player> show a player's cooldowns
set <id> <duration> [--network|-n] set a server cooldown - local by default, network with the flag
setplayer <player> <id> <duration> [--network|-n] set a player cooldown - local by default, network with the flag
reset <id> clear a server cooldown
resetplayer <player> <id> clear a player cooldown
reload reload the server cooldowns file
/eccooldown set myid 5m              -> server, local
/eccooldown set myid 5m --network    -> server, network
/eccooldown setplayer Notch myid 5m -n   -> player, network (short alias)

See also

Clone this wiki locally