Skip to content

Unlock Costs ​

unlock_costs.yml holds the price tiers for in-GUI paid expansion. It is a standalone business data file: release_default_data releases it only when the file is missing, and a version upgrade never overwrites content you have already edited

The file name comes from unlock.cost_file in config.yml (default unlock_costs.yml), and whether players may purchase at all depends on unlock.purchase_enabled together with the emakistorage.unlock.purchase permission — see Configuration

unlock_costs.yml Structure ​

yaml
tiers:
  - count_range: "1-9"
    currency:
      type: vault
      amount: 1000
  - count_range: "10-19"
    item:
      source: "diamond"
      amount: 16
  - count_range: "20-49"
    currency:
      type: vault
      amount: 50000
    item:
      source: "netherite_ingot"
      amount: 4

fallback:
  currency:
    type: vault
    amount: "100000 * 1.05 ^ (%count% - 50)"
  max_amount: 1000000000

batch:
  enabled: true
  options: [1, 5, 10, 64]
FieldTypeRequiredDefaultAccepted valuesDescription
tierslist<map>Nosee example (3 tiers)each element uses the key set belowThe price tier list. A tier with an invalid count_range or with neither price kind is skipped entirely with a warning
tiers[].count_rangestringYes— (a tier without it is skipped)"N" or "min-max"; requires min ≥ 1 and max ≥ minThe "which slot number" range this tier covers, such as 1-9. Earlier tiers take priority
tiers[].currencysectionNopresent on some shipped tierssee the three rows belowThe currency cost. At least one of currency and item must be present, or the tier is skipped
tiers[].currency.typeenumNovaultvault / excellentCurrency backend id. An unknown value falls back to vault
tiers[].currency.currencystringNo"" (empty)The backend's own currency identifierThe currency id handed to the economy backend; empty means the backend's default currency. The shipped file does not write this key
tiers[].currency.amountnumber or stringYes (when currency is written)—A non-negative number, or a string the expression engine can evaluateThe price. A number is used as-is; a string is evaluated by CoreLib's ExpressionEngine and may use %count%. When it is neither a number nor a non-blank string the currency block counts as unconfigured
tiers[].itemsectionNopresent on some shipped tierssee the two rows belowThe item cost
tiers[].item.sourcestringYes (when item is written)—CoreLib ItemSource tokenWhen it is missing or blank the item block counts as unconfigured
tiers[].item.amountintNo1 (when absent or not a number)> 0; <= 0 makes the item block count as unconfiguredRequired item count
fallbacksectionNosee examplesee the three rows belowFallback tier: the price of every slot beyond all tiers
fallback.currencysectionNosee examplesame key set as tiers[].currencyThe fallback tier's currency cost
fallback.itemsectionNononesame key set as tiers[].itemThe fallback tier's item cost. The fallback block is inert when both price kinds are absent
fallback.max_amountnumberYes (to use the fallback tier)—> 0Per-slot price cap. When it is missing or non-positive the fallback tier is disabled entirely and slots beyond tiers cannot be bought
batchsectionNoenabled: true, options: [1, 5, 10, 64]see the two rows belowBatch purchase options
batch.enabledbooleanNotruetrue / falseWhen false one click always buys exactly 1 slot
batch.optionslist<int>No[1, 5, 10, 64]Only positive integer elements; an empty result falls back to the defaultThe slot counts one click may buy. The current GUI only reads the first entry: clicking the unlock button always buys options[0] slots, and the remaining options have no entry point inside this module — they are kept as reserved capability

Per-Slot Pricing ​

Understand the pricing model first: a batch purchase is settled by pricing each slot and summing, not by unit price times count. When a player buys slot N, the count_range that N falls into decides that slot's price; N starts at purchasedSlots + 1 and increments. Charging the current unit price times the count would let a player buy a large batch at the cheapest tier and lock in that price for slots that belong in far more expensive tiers. The shipped batch.options: [1, 5, 10, 64] combined with a GUI that only reads the first entry means an actual order is normally a single slot, so the per-slot sum degenerates into a single quote; raise options[0] to buy several slots at once and the per-slot accumulation becomes visible

In expressions, %count% stands for "the slot being bought this time". When no tier matches, fallback is used

For example, coins for slots 1–5, diamonds for everything after, buying 10 slots at a time

yaml
tiers:
  - count_range: "1-5"
    currency:
      type: vault
      currency: "coin"
      amount: 500

fallback:
  item:
    source: "diamond"
    amount: 2
  max_amount: 100000

batch:
  enabled: true
  options: [10]

The max_amount Guard Rail ​

Note: fallback.max_amount is required. When it is missing or non-positive the fallback tier is disabled entirely and slots beyond tiers cannot be bought (reported as no_price_defined). Its purpose is to stop an exponential expression from overflowing into Infinity

Scope check: the guard rail only applies to the fallback tier. Slots matched by tiers are not limited by max_amount and are only constrained to a finite, non-negative amount (violations report price_overflow)

When a slot's computed price exceeds max_amount, the implementation explicitly refuses the quote (price_over_cap) instead of silently clamping to the cap: your own formula produced a price outside the guard rail you declared, so the purchase must not proceed

Purchase Entry Point and Pre-checks ​

There is exactly one purchase entry point: the unlock button in the GUI. Each click is checked in this order:

  1. unlock.purchase_enabled is true, otherwise the click reports the feature as disabled
  2. The player holds emakistorage.unlock.purchase, otherwise it reports no permission
  3. unlock_costs.yml provides a usable tier or a usable fallback, otherwise no_price_defined
  4. The purchase will not push past capacity.max_slots (effectiveSlots + slots > max_slots reports max_slots_reached)

The order size comes from the first entry of batch.options, see the batch.options row above

Payment Ordering and Reverse Compensation ​

One purchase runs in this order: quote, fire the cancellable StorageUnlockEvent, take currency, take items, then increase purchasedSlots

Nothing over-charges: any failure after a successful charge is compensated in reverse order, applied item removal plans are rolled back last-to-first, then the currency is refunded, and only then does the call return a failure. purchasedSlots is never touched on the compensation path

Failures are fail-closed: when no tier matches and no usable fallback exists, the purchase is refused, never treated as free

  • Configuration: unlock.purchase_enabled and unlock.cost_file
  • Capacity: where purchasedSlots sits in the four-source capacity model
  • GUI and Display: the slot configuration of the unlock button
  • Event API: the fields and cancellation semantics of StorageUnlockEvent