Repository navigation
Logic
The operations engine is how a plugin writes "who may do this, what it costs, and what happens" without
writing a listener for each: a gate (conditions and costs) and a list of effects, read from a plugin's
YAML or built in Java, run for an ExecutionContext. Menus, quests, region entry gates and shops are its
consumers; the engine itself lives in the core:
| Package | What is there |
|---|---|
logic |
ExecutionContext, Caller, ProgressOwner, Condition/Cost/Effect/Gate, Variable, RepeatPolicy, the registries Conditions/Costs/Effects/Variables/ProgressOwners
|
logic.core |
the stock types, registered by the core at load |
logic.audit |
AuditTrail, the gate events, LogicTrace
|
types |
TypeRegistry<T>, the self-describing registry every kind of type above lives in |
time |
Window and Season
|
player.state |
StateHolder and HeldPlayerState, behind scope: held
|
| Word | Meaning |
|---|---|
| condition | a question about a context that never charges: passed, or failed with a text that says why |
| cost | a condition that also charges: test only looks, take takes, refund gives back. A cost is a condition too: every cost type can sit in conditions:/require:, where it only tests and takes nothing |
| gate | conditions plus costs: check lists every failure, pay takes the costs |
| effect | a change: a message, a command, a value set, a game mode held |
| variable | a value read for a context (exp-level, playtime, a plugin's own counter); a mutable one can be set |
| caller | the plugin and the readable origin that ran it - finalmenus:shop/vip#slot12
|
| owner | whose progress a counter or variable belongs to: player:<uuid>, global, region:<id>, world:<name>, or a plugin's kind (group:<id>) |
Everything runs for an ExecutionContext, built with a mandatory Caller:
ExecutionContext ctx = ExecutionContext.builder(Caller.of(getPluginData(), "shop/vip#slot12"))
.player(fplayer) // null for a preview, a scheduler task or a global owner
.owner(ProgressOwner.region(arena)) // default: player(uuid) with a player, global() without
.variable("missing", "3 coins") // {missing} in the texts this run resolves
.attribute("screen", screen) // a typed extension: getAttribute("screen", Screen.class)
.build();
ExecutionContext next = ctx.derive().player(other).build(); // everything else kept-
ctx.resolve(text)fills{key}(the context's variables, then any registered variable read for the player -{health},{finaljobs:level}- then{player},{owner},{caller}), then%placeholders%through PlaceholderAPI when there is a player. A{key}nobody answers stays written, never blank. -
ctx.getLocale()is the player's locale, or the calling plugin's language without one. -
ctx.getMultiplier(variableId)is the product of every registeredMultiplierProvider(Multipliers.register(plugin, provider)),1without one. -
ctx.getAudit()is theAuditTrailof the run; a derived context writes to the same one. - The context never changes thread: conditions and costs run on the caller's thread, effects go through
Effects.apply(see threads).
Gate gate = Gate.builder()
.require(Conditions.atLeast(1, levelTen, minerFive), Conditions.optional(playedAnHour))
.cost(RepeatPolicy.limited(3).window(daily).asCost("vip-door"))
.build();
GateResult checked = gate.check(ctx); // PASSED or DENIED, with every failure in declaration order
GateResult paid = gate.pay(ctx); // PAID, DENIED (nothing taken) or REFUNDED (taken, then given back)
if (!paid.isPassed()) {
player.sendMessage(paid.explain()); // one failure per line
}-
checktests every condition and every cost (without taking) and lists every failure - the greyed lore and the "you still need" dialog readgetFailures(). -
paychecks, posts the cancellableGatePrePayEvent, then takes each cost in order. Atakethat answersfalseor throws refunds the costs already taken in reverse order; a refund that throws is audited asREFUND_FAILEDand logged severe on the caller's log ("give it back by hand"). There is no lock: each cost'stakeis atomic on its own. - A condition that throws counts as failed with the exception's text (
CONDITION_THREW); it never breaks the gate. -
getNeeds()is the union of the event sources its conditions react to - what a consumer listens for before re-checking.
Compositions are condition types like any other, with Java constructors in Conditions:
| Type | Passes when | Fails with |
|---|---|---|
all |
every child passes | the failure of every child that failed |
any |
one child passes | every failure, when none passed |
none |
no child passes | the child that passed |
at-least (count) |
count children pass |
the failures, when fewer passed |
optional (condition) |
always | never - the child's failure rides along as a note (GateResult.getNotes()) |
Effects.parse(caller, node, zone) reads one effect; Effects.parseList a list. Effects.apply(ctx, effect) is
the only way to apply one. A failing effect is audited as EFFECT_FAILED and logged; the next ones still run. An
effect that refuses and audits its own EFFECT_FAILED (open-screen without a player, a money effect the wallet
refused) is not also audited as EFFECT_APPLIED.
Every effect takes the modifiers, read by the engine and never by the type:
| Key | Meaning |
|---|---|
delay: 2s |
wait before applying; the player is looked up again when it fires |
chance: 0.5 |
apply with that probability |
if: <condition> |
apply only when the condition passes |
repeat: 3 + every: 10s
|
apply N times, T apart |
as: player|console |
who runs it - only for a type that takes a sender (command); anywhere else it is a parse error |
target: self|group|region-players|world-players|online-players |
whom it applies to; each target gets its own derived context, its owner's provider lists them. online-players is every online player (the members of ProgressOwner.global()), whatever the owner of the context |
scope: permanent|held |
held holds the change for the context's StateHolder and gives it back when it lets go - only for a holdable type |
to: <destination> |
where what the effect gives goes: inventory, pending or a plugin's destination - only for a type that hands something over (give-item, money); see Deliveries
|
A holdable effect (gamemode, fly, potion-effect, player-time, player-weather) changes one slot of the
player's state through HeldPlayerState.of(uuid), held by a StateHolder: the one the consumer put in the
context under HoldableEffect.HOLDER_ATTRIBUTE (a quest stage, a flag), or the caller without one. The first holder
of a slot saves what the player had, the last one to let it go writes it back, and a quit gives back whatever is
still held. While others still hold the slot, the latest holder letting go makes the holder now latest write its own
value again (HeldPlayerState.hold(slot, holder, value, rewrite, persistent, online) takes that rewrite): a
scope: held gamemode released inside a game-mode region gives back the region's mode. A region flag is a StateHolder too (by its key), so a flag and an effect holding the same slot share
the rule. scope: held on an effect that cannot be held fails the parse and names the alternatives.
Effects.apply runs the effect in place when the caller is already on the player's world thread, and queues it
there otherwise - the world's executor on Hytale, the main thread on Bukkit; without a player it hops with
ECScheduler.onMainThread. A delayed or repeated effect whose player left goes to the DeferredEffectSink
(DeferredEffects.register), as defer(ctx, effect, JsonNode node, UUID absentPlayer) - node is the effect's own
node without the modifiers that already ran (delay, every, repeat, target, chance, if). The core starts
Deliveries at enable, and Deliveries.start() registers a sink that turns it into a delivery the
player claims on login (only the core's own effect types; a plugin's is audited DELIVERY_DISCARDED).
Deliveries.closeAll() puts back the default sink, which drops it and audits DEFERRED_DROPPED; a share of a reward
is always dropped (its node says the whole amount).
A variable registered in Variables.registry() comes with everything for free: the compare condition, the
set/add effects (when mutable) and {id} in any resolved text. Variable.getType() decides how compare
reads it: BigDecimal with < <= = >= > !=, String with = != contains, Boolean with = !=, Duration as
seconds.
A plugin's own variable, one value per owner, kept in its storage:
CustomVariables mine = Variables.custom(getPluginData(), storage); // collection ec_variables
CustomVariables.CustomVariable<BigDecimal> earned = mine.define("daily-money-earned", BigDecimal.class, BigDecimal.ZERO)
.window(Window.daily(LocalTime.of(4, 0), Window.getServerZone())); // starts over each windowIt registers as the variable type <plugin>:<name>, so a text reads {fakeshop:daily-money-earned} and a
definition writes {type: compare, variable: fakeshop:daily-money-earned, op: ">=", amount: 1000}.
Variables.custom(plugin) keeps the values in memory only.
A currency's balance is the variable named by the currency's id - there is no balance variable. Registering a
currency (Economy) registers a variable of its id that reads the balance of the context's owner:
{type: compare, variable: money, op: ">=", amount: 100} (the bare name of evernifecore:money), or
{finalpoints:points} in a text.
RepeatPolicy (once, limited(n), unlimited(), .cooldown(gap), .window(window), .reach(...),
.persistent(...)) is the limit of a use, stored by the Cooldown handle. In a gate it is a cost -
policy.asCost(counterId), or the stock cost repeat in a definition - charged per owner of the context. The
handle, its routes and the counters of region, world and group owners (Cooldowns.of(plugin, storage)) are on
Cooldowns.
time.Window is a recurring boundary in a ZoneId, safe across daylight saving:
| Factory | Text form |
|---|---|
Window.daily(LocalTime, zone) |
daily at 04:00 |
Window.weekly(DayOfWeek, LocalTime, zone) |
weekly on monday at 18:00 zone Europe/Lisbon |
Window.monthly(day, LocalTime, zone) |
monthly on 1 (29-31 clamp to the month's last day) |
Window.cron(fiveFields, zone) |
cron 0 4 * * 1 (*, */n, a-b, a,b, month and day names) |
Window.season(Season.of(id, from, to)) |
{season: {id, from, to, zone}} |
currentStart(now) is the latest boundary not after now, nextBoundary(now) the next one, isOpen(now) false only
for a season outside its interval. A boundary in the spring gap moves forward (02:30 -> 03:30), one in the autumn
overlap takes the first occurrence. A cron that can never match is a parse error that says why. Without a zone,
the server's (Window.getServerZone(), the ZONE_ID_OF_DAY_OF_TODAY setting) applies.
Every kind lives in a TypeRegistry, keyed owner:name - the owner is the registering plugin:
Conditions.registry().register(getPluginData(), TypeDescriptor.<Condition>of("finaljobs:has-job")
.describe("Passes when the player has the job.")
.param(ParamSpec.required("job", ParamSpec.Kind.ID, "The job's id, e.g. miner."))
.factory(params -> new HasJob(params.getString("job")))
.build());- The registry validates a node against the parameters before the factory runs, and every refusal names the type, the parameter, the value received, the expected format and the type's parameters.
- A node is
{type: <key>, <param>: <value>...}, or<key>: <value>for a type with one required parameter (permission: shop.vip). A bare name resolves to the caller's own type first, then to the only one that has it; an ambiguous one lists the owners. - A definition citing a type nobody registered yet is kept pending and resolves when the type arrives - a gate read before FinalJobs enabled works once it does; until then it fails with that cause.
-
.platforms(...)and.needsCapability(...)make a type fail at parse on a platform that cannot run it (TypeRegistry.getUnsupportedanswers without throwing). On Hytale todayexp-level,exp-points,foodandstatare unsupported. -
Costs.register(plugin, descriptor)puts a cost in both the cost and the condition registry: a cost is a condition as well, and insideconditions:({type: item, item: "minecraft:diamond", amount: 3}) it only tests and takes nothing. - A plugin's types and pending definitions leave with it (
TypeRegistries.unregisterAllon shutdown).
| Kind | Types |
|---|---|
| condition |
all, any, none, at-least, optional, compare (variable, op, amount), permission (node) |
| cost |
repeat (counter, limit, cooldown, window, reach, persistent), money (amount - a number or a {variable} -, currency, default money, its {variables} resolved when it runs: "finalpoints:{points-currency}"), item (item id - a bare name matches any namespace -, amount, data - see Items and NBT, -1 any; taken from the inventory all or nothing); a money cost whose currency is not registered fails its test with what to do, and its take answers false |
| effect |
money (amount, currency - {variables} resolved when it runs; one not registered then is audited EFFECT_FAILED, logged once per id, and a claim keeps it pending -, boosted default true - see Boosters), give-item (item, amount, data, expires; what does not fit becomes a delivery), message (text), command (command, takes as), set/add (variable, amount), teleport (world, x, y, z), open-screen (screen, args), start-dialogue (dialogue, node) / end-dialogue - see Dialogues; holdable: gamemode (value), fly (value), potion-effect (effect, level, duration), player-time (value, relative), player-weather (value) |
| variable |
exp-level, exp-points, health, food, gamemode (mutable), permission (node), placeholder (text), playtime, stat (name), custom (id) |
| owner |
player, global, region, world
|
The full, current table - with every parameter, default, platform and capability - is generated, not written:
/ecore types export (below).
# read with Gate.parse(caller, node, zone) and Effects.parseList(caller, node, zone)
gate:
require:
- { type: permission, node: shop.vip }
- type: at-least
count: 1
conditions:
- { type: compare, variable: exp-level, op: ">=", amount: 10 }
- { type: compare, variable: finaljobs:miner-level, op: ">=", amount: 5 } # pending until FinalJobs registers it
- { type: optional, condition: { type: compare, variable: playtime, op: ">=", amount: 3600 } }
cost:
- { type: repeat, counter: vip-door, limit: 3, window: "daily at 04:00", reach: network }
effects:
- { type: message, text: "&aWelcome, {player}!" }
- { type: command, command: "give {player} minecraft:diamond 1", as: console, delay: 2s }
- { type: gamemode, value: adventure, scope: held } # given back when the holder lets go
- { type: teleport, world: world, x: 100, y: 65, z: -20, chance: 0.5, if: { type: permission, node: shop.vip.teleport } }Every step writes an AuditEntry (kind, caller, owner, instant, text, optional amount and cause) to the
context's AuditTrail: CONDITION_FAILED, CONDITION_THREW, COST_TAKEN, COST_REFUNDED, REFUND_FAILED,
EFFECT_APPLIED, EFFECT_FAILED, DEFERRED_DROPPED, GATE_DENIED, GATE_PAID, HELD, RELEASED. The trail is
in memory, one per run; an AuditLog is the persisted record a plugin keeps of what it moved.
On the bus, each only when something listens: GateCheckEvent, GatePrePayEvent (cancellable), CostTakeEvent,
CostRefundEvent, EffectApplyEvent (cancellable, setEffect replaces what runs), GatePaidEvent,
GateDeniedEvent - see Events.
/ecore logic trace <player> [plugin] (permission evernifecore.command.logic) toggles, for whoever typed it, a
line per condition checked, cost taken or refunded, effect applied and multiplier of that player's contexts - only
the callers of [plugin] when one is named - with the caller in front:
[finalmenus:shop/vip#slot12] condition compare(exp-level >= 10) -> passed
[finalmenus:shop/vip#slot12] check Gate(...) -> PASSED
[finalmenus:shop/vip#slot12] COST_TAKEN 1 use of vip-door (2 of 3 left)
[finalmenus:shop/vip#slot12] EFFECT_APPLIED message
The same command again turns it off; a player who watches stops on quit. The debug module LOGIC (off by default,
see Logging & Debug Modules) writes the same lines to the log for every player.
/ecore types export [kind] (permission evernifecore.command.types) writes export/types-<kind>.md into the
core's folder - one file per kind (condition, cost, effect, owner, variable), or only the one named - with
a table per registered type: who registered it, aliases, description, parameters with kind, required and default,
platforms, sources, capabilities and whether this platform runs it. The page describes the server it ran on,
third-party types included.
LogicWorld in common-tests is one plugin that owns logic - contexts with its caller, online players, its
storage, the stock types registered and the shared test clock; trace(player) hands back the lines a trace would
send. See Testing Toolkit.
-
Cooldowns - the
Cooldownhandle behindRepeatPolicy. -
Regions - flags hold player state through the same
HeldPlayerState. - Events - the bus the gate events ride.
- Scheduler & Threading - where an effect runs.
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Block Data (SVWorldDataManager)
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Logging & Debug Modules
- Events
- Regions
- Groups
- Notifications
- NPCs
- Logic
- Quests
- Provenance
- Scheduler & Threading
- Items & NBT
- Screens
- Dialogues
- Menus
- Integrations
- Economy
- Boosters
- Deliveries
- Audit Log
- Dynamic Values
- Player Ignore
- Version Compatibility
Architecture & Reference