Skip to content

Dynamic Values

Pétrus Pradella edited this page Nov 2, 2026 · 3 revisions

Dynamic Values and Contributions

Two numeric engines of br.com.finalcraft.evernifecore.logic.value, both kept per plugin in the plugin's own storage:

  • a DynamicValue moves with use and drifts back - a price that rises with each sale, a pay rate that cools down;
  • a Contribution is a goal several players fill together - a clan warehouse, a town's donation target.

DynamicValue

DynamicValues values = DynamicValues.of(getPluginData(), storage);          // or DynamicValues.of(plugin): memory only
DynamicValue price = values.define("diamond-price", DynamicValueSpec.base(100)
        .min(new BigDecimal("50"))
        .max(new BigDecimal("400"))
        .step(new BigDecimal("5"))                                         // each bump
        .decay(new BigDecimal("1"), Duration.ofMinutes(10))               // back toward the base, 1 every 10 minutes
        .build());

ProgressOwner market = ProgressOwner.global();
BigDecimal now = price.get(market);
price.bump(market);                       // +step, never past max
price.bump(market, new BigDecimal("-20")); // any delta, never past min
price.set(market, new BigDecimal("120"));
price.reset(market);                      // back to the base
  • One state per ProgressOwner - global, per player, per group, per region.
  • The decay is applied on read, from the stored instant and ECClock: no sweep, no scheduler; it pulls toward the base without passing it.
  • DynamicValueSpec.spec(NumericSpec) rounds every value; clamp(value) keeps it in min/max.
  • A dynamic value is a variable: {myplugin:diamond-price} reads (and set writes) the context owner's value in any Logic definition, a price included.
  • Storage: collection ec_dynamic_values, key <id>@<owner>. define(id, spec) accepts name or <plugin>:name (another plugin's id is refused); find(id) looks one up. DynamicValues.closeOwnedBy(owner) runs at shutdown. DynamicValues.of(owner) without a storage returns the open instance, or a memory-only one when none is open; reopening with another storage waits for the writes of the instance it replaces (so do Contributions.of and BoosterManager.of).
  • In a config a spec is written {base: 100, min: 50, max: 400, step: 5, decay: {amount: 1, every: 10m}} (codec registered by the core).

Any thread; the writes of one owner are serialized, and the storage ends with the owner's latest state whatever order the backend completes writes in (one write per row on its way; a change meanwhile writes again once it lands).

Contribution

Contributions goals = Contributions.of(getPluginData(), storage);
Contribution<Currency> fund = goals.define("town-hall", ProgressOwner.of("group", clanId),
        CurrencyRequirement.of(Currencies.money(), new BigDecimal("10000")));
fund.onComplete(Effects.parse(getPluginData(), node, zone));

DepositResult result = fund.deposit(ExecutionContext.builder(caller).player(player).build());
result.getCounted();   // what this deposit added
result.getTotal();     // the total so far
result.isCompleted();  // this deposit reached the target
  • Each deposit(ctx) takes from the context's player what is still missing, by member (getByMember()), up to the target (getTotal(), isComplete()). Reaching it applies the onComplete effects once, with the completing deposit's context.
  • Requirements: CurrencyRequirement.of(currency, amount) (from the player's own wallet) and ItemRequirement.of(ItemMatcher, count) (from the player's inventory). Both consume by default; .check() only counts what the member holds, and counts only what they hold beyond what already counted.
  • Events: ContributionDepositEvent per deposit (getAmount(), getTotal()), ContributionCompletedEvent once. Each deposit is recorded as DEPOSIT in the owner's audit log, when it has one.
  • Storage: collection ec_contributions, key <id>@<target>; what a deposit writes is a snapshot of the row's latest state, one write per row on its way, so two deposits whose writes complete out of order still leave the last total in storage.
  • Threads. Any thread. The requirement (take/countHeld) runs while the contribution is not locked: a wallet or an inventory may wait for the player's thread, and that thread may be reading getTotal() for a menu. Deposits racing for the last of the target are counted one at a time, and the total never passes the target: what one took past it goes back through ContributionRequirement.giveBack(ctx, amount) - a currency to the wallet, items to the inventory and what does not fit as a delivery. A giveBack that throws is audited REFUND_FAILED and logged severe on the plugin's log, never thrown out of deposit.
  • A custom ContributionRequirement implements getSubject, getTarget, isConsuming, countHeld(ctx), take(ctx, upTo) (what actually left, up to upTo) and giveBack(ctx, amount).

Testing

LogicWorld.dynamicValues() and LogicWorld.contributions() give the fake plugin's managers over an in-memory storage, closed with the world, sharing its test clock (advance(Duration) moves the decay). See Testing Toolkit.

Clone this wiki locally