Skip to content

ItemCurrencyAsset — Currency System and Vendor Integration

Overview

ItemCurrencyAsset is unique among the SDK's asset types: it extends Asset directly, not ItemAsset. It is not an item — it is a registry that associates one or more item types with monetary values, defining a currency system. A single ItemCurrencyAsset can define multiple denominations (e.g., $1, $5, $20 bills) and provides methods for calculating a player's total wealth, checking affordability, granting value (adding currency items to inventory), and spending value (removing currency items with change-making).

The currency asset is the backbone of NPC vendor transactions. When a player buys an item from a vendor, the vendor references a ItemCurrencyAsset to determine which items count as currency, how to value the player's inventory, and how to handle overpayment with refunds.

Inheritance Chain

Asset
  └── ItemCurrencyAsset

ItemCurrencyAsset extends Asset directly. It does not descend from ItemAsset, ItemBarricadeAsset, or any item hierarchy. This means currency assets:

  • Cannot be placed in inventory (they are not items).
  • Cannot be crafted, equipped, or consumed.
  • Do not have rarity, size, slot, or quality fields.
  • Exist purely as configuration data in the asset registry.
  • Are referenced by GUID or asset reference, not by item ID.

The Entry Struct

Each currency denomination is represented by an Entry struct:

csharp
public struct Entry
{
    public AssetReference<ItemAsset> item;
    public uint value;
    public bool isVisibleInVendorMenu;
}

item

An AssetReference<ItemAsset> — a serializable reference to an item asset. At runtime, entry.item.Find() resolves the reference to the actual ItemAsset instance. If the referenced asset doesn't exist (deleted item, missing mod dependency), Find() returns null.

The reference is a GUID-based lookup, not an ID-based lookup. This is distinct from most item references in the SDK which use ushort IDs. The GUID approach allows cross-mod currency references — a currency in Mod A can reference an item from Mod B via GUID.

value

A uint representing the item's worth in the abstract base currency unit. The currency system has no named unit (no "dollars" or "gold") — all values are in an abstract unit defined by the currency asset's configuration.

Values are positive integers. A value of 0 would mean the item contributes nothing to wealth calculations, which is functionally useless — the spendValue method would never select a zero-value entry for payment.

isVisibleInVendorMenu

A boolean controlling whether this denomination appears in the vendor's "accepted currencies" list. When false, the item can still be used for payment — it just doesn't show up in the vendor UI. This is designed for "stacked" currency items (e.g., a stack of 100x $20 bills as a single inventory item) that would clutter the vendor display.

The default is true (visible):

csharp
if (entryReader.ContainsKey("Is_Visible_In_Vendor_Menu"))
    entry.isVisibleInVendorMenu = entryReader.ParseBool("Is_Visible_In_Vendor_Menu");
else
    entry.isVisibleInVendorMenu = true;

Core Fields

valueFormat

csharp
public string valueFormat { get; protected set; }

A format string for displaying currency values. The string contains {0} as a placeholder for the numeric value. Examples:

valueFormatDisplay for value 100
"${0}"$100
"{0} gold"100 gold
"{0}"100
"¤{0}"¤100

Parsed from the .dat key ValueFormat:

csharp
valueFormat = p.data.GetString("ValueFormat");

If absent, valueFormat is null or empty. The vendor UI should fall back to a default format or display the raw number.

defaultConditionFormat

csharp
public string defaultConditionFormat { get; protected set; }

A format string for NPC conditions that require a value comparison (e.g., "You need $100 / have $45"). The string uses {0} for the required value and {1} for the player's available value.

Parsed from .dat key DefaultConditionFormat, with a fallback auto-generation:

csharp
defaultConditionFormat = p.data.GetString("DefaultConditionFormat");
if (string.IsNullOrEmpty(defaultConditionFormat) && !string.IsNullOrEmpty(valueFormat))
{
    defaultConditionFormat = valueFormat + " / " + valueFormat.Replace("{0", "{1");
}

If DefaultConditionFormat is not specified, the system derives it from valueFormat:

  • valueFormat = "${0}"defaultConditionFormat = "${0} / ${1}"
  • valueFormat = "{0} gold"defaultConditionFormat = "{0} gold / {1} gold"

The .Replace("{0", "{1") pattern converts the {0} placeholder to {1}. Note it replaces "{0" (without the closing brace) to avoid matching a {0} within a larger format token.

entries

csharp
public Entry[] entries { get; protected set; }

The array of currency denominations, sorted by value ascending. The sort is performed by ItemCurrencyComparer during PopulateAsset:

csharp
System.Array.Sort(entries, valueComparer);

The sort order is critical for the grantValue and spendValue algorithms:

  • grantValue iterates HIGHEST to LOWEST (to grant the fewest items).
  • spendValue iterates LOWEST to HIGHEST (to spend the smallest possible denominations).

PopulateAsset — Entry Parsing

The entries are parsed from a hierarchical .dat structure:

csharp
if (p.data.TryGetList("Entries", out IDatList entryNodes))
{
    int numberOfItems = entryNodes.Count;
    entries = new Entry[numberOfItems];
    for (int index = 0; index < numberOfItems; ++index)
    {
        Entry entry = new Entry();

        if (entryNodes[index] is IDatDictionary entryReader)
        {
            entry.item = entryReader.ParseStruct<AssetReference<ItemAsset>>("Item");
            entry.value = entryReader.ParseUInt32("Value");

            if (entryReader.ContainsKey("Is_Visible_In_Vendor_Menu"))
                entry.isVisibleInVendorMenu = entryReader.ParseBool("Is_Visible_In_Vendor_Menu");
            else
                entry.isVisibleInVendorMenu = true;
        }

        entries[index] = entry;
    }
}
else
{
    entries = new Entry[0];
}

The .dat structure uses a list of dictionaries:

Entries
{
    {
        Item <GUID>
        Value 1
    }
    {
        Item <GUID>
        Value 5
    }
    {
        Item <GUID>
        Value 20
        Is_Visible_In_Vendor_Menu false
    }
}

Each entry must provide at minimum Item (a GUID reference to an ItemAsset) and Value (a positive integer).

getInventoryValue — Wealth Calculation

csharp
public uint getInventoryValue(Player player)

Calculates the total currency value of all matching items in a player's inventory:

csharp
uint totalInventoryValue = 0;

foreach (Entry entry in entries)
{
    ItemAsset itemAsset = entry.item.Find();
    if (itemAsset == null)
        continue;

    using (ScopedPlayerInventorySearchResultPool scope = new ScopedPlayerInventorySearchResultPool())
    {
        player.inventory.FindItemsByAsset(scope.PooledResults, itemAsset, false, true);
        foreach (PlayerInventorySearchResultV2 result in scope.PooledResults)
        {
            totalInventoryValue += result.Jar.item.amount * entry.value;
        }
    }
}

return totalInventoryValue;

Algorithm

  1. Iterate all entries (denominations).
  2. For each entry, resolve the AssetReference<ItemAsset> to an actual ItemAsset.
  3. If the item asset doesn't exist (null), skip the entry.
  4. Search the player's inventory for all items matching that asset:
    • FindItemsByAsset searches all inventory pages.
    • The false parameter means "don't include equipped clothing pockets" (or similar filtering).
    • The true parameter means "include items in the hotbar" (or similar inclusion).
  5. For each matching inventory slot, add item.amount * entry.value to the total.
  6. Return the accumulated total.

ScopedPlayerInventorySearchResultPool

The using block with ScopedPlayerInventorySearchResultPool provides a pooled list for search results. This avoids allocating a new list per query. When the using block exits, the list is returned to the pool for reuse. This pattern is used throughout the SDK for inventory searches.

Example Calculation

Entries:
  Entry 0: $1 bill  (value: 1)
  Entry 1: $5 bill  (value: 5)
  Entry 2: $20 bill (value: 20)

Player inventory:
  3x $1 bills   → 3 × 1  = 3
  1x $5 bill    → 1 × 5  = 5
  2x $20 bills  → 2 × 20 = 40
  Total                   = 48

If the player also has stackable currency (e.g., 10x $1 bills in one slot), item.amount returns 10, and the contribution is 10 * 1 = 10.

canAfford — Affordability Check

csharp
public bool canAfford(Player player, uint value)
{
    return getInventoryValue(player) >= value;
}

A simple threshold check. Delegates to getInventoryValue and compares against the required value. No side effects — the player's inventory is not modified.

Vendors call canAfford before showing a "Buy" prompt. If false, the prompt is disabled or shown in red with an insufficient-funds message.

grantValue — Adding Currency to Inventory

csharp
public void grantValue(Player player, uint requiredValue)

Adds currency items to the player's inventory to reach the target value, using the fewest items possible:

csharp
if (requiredValue < 1)
    return;

for (int index = entries.Length - 1; index >= 0; --index)
{
    Entry entry = entries[index];
    ItemAsset itemAsset = entry.item.Find();
    if (itemAsset == null)
        continue;

    if (requiredValue < entry.value)
        continue;

    uint requiredAmount = requiredValue / entry.value;
    ItemTool.tryForceGiveItem(player, itemAsset.id, (byte)requiredAmount);
    requiredValue -= requiredAmount * entry.value;

    if (requiredValue == 0)
        return;
}

Algorithm (Highest-to-Lowest)

  1. If requiredValue < 1, do nothing (can't grant 0 or negative value).
  2. Iterate entries from HIGHEST value to LOWEST (entries.Length - 1 down to 0).
  3. For each entry: if the entry's value exceeds the remaining required value, skip it (can't use a $20 to pay $13 — that would over-grant).
  4. Calculate the integer number of items needed: requiredValue / entry.value (floor division).
  5. Force-give that many items to the player's inventory via ItemTool.tryForceGiveItem.
  6. Subtract the granted value from requiredValue.
  7. If requiredValue reaches 0, return early.
  8. If the loop exhausts all entries and requiredValue is still > 0, the remaining value cannot be represented by the available denominations.

Rounding Down

Because requiredAmount uses integer division (floor), the grant always rounds down. A requiredValue of 13 with entries [1, 5, 20]:

  1. Check $20: 13 < 20 → skip.
  2. Check $5: 13 / 5 = 2 → grant 2× $5 ($10). requiredValue remaining: 3.
  3. Check $1: 3 / 1 = 3 → grant 3× $1 ($3). requiredValue remaining: 0.
  4. Done.

With entries [5, 20] only (no $1):

  1. Check $20: skip.
  2. Check $5: 13 / 5 = 2 → grant 2× $5 ($10). requiredValue remaining: 3.
  3. Loop ends (no more entries).
  4. requiredValue is still 3 — the player receives $10 and the remaining $3 is lost (not granted).

ItemTool.tryForceGiveItem

The tryForceGiveItem method adds items to the player's inventory, handling:

  • Stacking: if the player already has the item, the new items stack onto the existing stack.
  • Overflow: if the stack reaches max, remaining items overflow to the next available slot.
  • Inventory full: if no slot is available, the item drops at the player's feet as a world pickup.
  • The (byte) cast limits to 255 items per grant call. For values requiring more than 255 of a single denomination, the grant would need multiple calls or a batch variant.

Usage Contexts

grantValue is used for:

  • Vendor "sell" transactions: the vendor pays the player for sold items.
  • Quest rewards: NPCs grant currency as quest completion rewards.
  • Refunds from spendValue: when a player overpays, the difference is granted back.
  • Admin commands: giving currency to players.
  • Loot drops: granting currency as part of a loot table.

spendValue — Paying with Currency

csharp
public bool spendValue(Player player, uint requiredValue)

Removes currency items from the player's inventory to pay a cost, with change-making:

csharp
if (canAfford(player, requiredValue) == false)
    return false;

uint spentValue = 0;

foreach (Entry entry in entries)
{
    ItemAsset itemAsset = entry.item.Find();
    if (itemAsset == null)
        continue;

    uint valueRemaining = requiredValue - spentValue;
    uint idealAmount = ((valueRemaining - 1) / entry.value) + 1;

    using (ScopedPlayerInventorySearchResultPool scope = new ScopedPlayerInventorySearchResultPool())
    {
        player.inventory.FindItemsByAsset(scope.PooledResults, itemAsset, false, true);

        foreach (PlayerInventorySearchResultV2 item in scope.PooledResults)
        {
            uint amountDeleted = item.DeleteAmount(player, idealAmount);
            idealAmount -= amountDeleted;
            spentValue += amountDeleted * entry.value;

            if (idealAmount == 0)
                break;
        }
    }

    if (spentValue >= requiredValue)
        break;
}

if (spentValue > requiredValue)
{
    uint valueDue = spentValue - requiredValue;
    grantValue(player, valueDue);
}

return true;

Algorithm (Lowest-to-Highest)

  1. Affordability check: Call canAfford(player, requiredValue). If the player can't afford it, return false immediately.
  2. Iterate entries LOWEST to HIGHEST: Iterate entries forward (index 0 to entries.Length - 1). This spends the smallest denominations first, leaving larger bills for change-making flexibility.
  3. Calculate ideal amount: For each entry, calculate how many items of this denomination would be needed to cover the remaining value:
    valueRemaining = requiredValue - spentValue
    idealAmount = ceil(valueRemaining / entry.value)
    The ceiling division ((valueRemaining - 1) / entry.value) + 1 ensures we take enough to cover or exceed the remaining value. For example, valueRemaining = 35, entry.value = 20idealAmount = ((34) / 20) + 1 = 1 + 1 = 2 (two $20 bills = $40, enough to cover $35).
  4. Search inventory: Find all items matching this denomination.
  5. Delete items: For each matching inventory slot, call DeleteAmount to remove up to idealAmount items. Track how many items were deleted and how much value was spent.
  6. Check if done: If spentValue >= requiredValue, break the outer loop.
  7. Refund: If spentValue > requiredValue, the player overpaid. Calculate the difference and call grantValue to refund it.

Change-Making (Refund)

The refund step handles overpayment. Example: player needs to pay $35, has 2× $20 bills:

  1. Iterate $1 bills: no $1 bills. spentValue = 0.
  2. Iterate $5 bills: no $5 bills. spentValue = 0.
  3. Iterate $20 bills: idealAmount = ceil((35 - 0) / 20) = ceil(1.75) = 2. Delete 2× $20. spentValue = 40.
  4. spentValue (40) >= requiredValue (35) → break.
  5. Refund: valueDue = 40 - 35 = 5. Call grantValue(player, 5).
  6. grantValue(5): check $20 (skip), check $5 (grant 1), done. Player spends 2× $20 and receives 1× $5 back.

Rounding Issues in Refund

The refund via grantValue has the same rounding-down behavior. If the refund value can't be perfectly represented by available denominations, the player loses the difference:

  • Player pays $40 (2× $20), needs $35, owed $5.
  • Available denominations: $20 only (no $1, $5, $10).
  • grantValue(5): check $20 (skip — 5 < 20). Loop exhausts. No refund granted.
  • Player effectively paid $40 for a $35 item — lost $5.

This is a known design limitation. The currency system assumes denominations that can represent all values up to some reasonable granularity. A single-denomination currency (e.g., only $20 bills) cannot make change and will always result in lost overpayment.

DeleteAmount Behavior

DeleteAmount removes items from a stack and returns how many were actually removed. If the stack has fewer items than requested, all available items are removed. The method handles:

  • Partial stack removal: stack decremented, slot persists.
  • Full stack removal: slot cleared.
  • Stacked items: the amount field of the Item instance decremented.
  • Return value: the actual count removed (may be less than requested if the stack was smaller).

Ceiling Division Detail

The idealAmount calculation uses integer ceiling division:

csharp
uint idealAmount = ((valueRemaining - 1) / entry.value) + 1;

This pattern computes ceil(valueRemaining / entry.value) without floating-point math:

valueRemainingentry.valueCalculationResult
3520(34 / 20) + 1 = 1 + 12
4020(39 / 20) + 1 = 1 + 12
2020(19 / 20) + 1 = 0 + 11
520(4 / 20) + 1 = 0 + 11
020(4294967295 / 20) + 1Overflow!

The last case (valueRemaining = 0) underflows valueRemaining - 1 (unsigned), producing uint.MaxValue. Then MaxValue / 20 + 1 is a huge number, causing the search to attempt to delete far more items than exist. This case should never occur because spentValue >= requiredValue triggers before valueRemaining reaches 0. If a bug causes this, the system would try to delete billions of items.

ItemCurrencyComparer

csharp
internal class ItemCurrencyComparer : Comparer<ItemCurrencyAsset.Entry>
{
    public override int Compare(ItemCurrencyAsset.Entry x, ItemCurrencyAsset.Entry y)
    {
        return x.value.CompareTo(y.value);
    }
}

A static comparer instance (valueComparer) sorts entries by value ascending. The sort is performed once during PopulateAsset after all entries are parsed:

csharp
System.Array.Sort(entries, valueComparer);

The ascending sort ensures:

  • grantValue (iterates high-to-low) processes largest denominations first → fewest items granted.
  • spendValue (iterates low-to-high) processes smallest denominations first → best granularity for change-making.

Performance Considerations

getInventoryValue: Per-Player Inventory Scans

Each call to getInventoryValue performs a full inventory scan per currency entry. For a currency with 3 denominations and a player with 50 inventory slots, this is 3 scans of 50 slots = 150 slot checks. For a vendor UI that calls getInventoryValue every frame (to update the player's displayed wealth), this is 150 checks per frame per player.

Vendor UIs should cache the player's wealth value and only re-scan when the inventory changes (detected via inventory change events) or at a throttled rate (every 0.5 seconds rather than every frame).

canAfford: Redundant Scan

canAfford calls getInventoryValue, which performs a full scan. Then, if the result is true, spendValue calls canAfford again (another full scan) before performing its own iteration. This is two full scans for a single purchase. A vendor implementation could cache the canAfford result from the UI check and pass it to the spend flow to avoid the redundant scan.

ScopedPlayerInventorySearchResultPool

The pooled search result list avoids allocation overhead. Each using block rents a list from the pool, populates it during FindItemsByAsset, iterates it, and returns it to the pool. The pool is sized for typical server concurrency — under heavy vendor traffic (many players opening vendor UIs simultaneously), the pool may need to grow, which does allocate new lists.

Cargo Data Export

ItemCurrencyAsset does not override BuildCargoData. As a direct Asset subclass (not ItemAsset), it inherits only the base Asset cargo export, which writes minimal fields (GUID, asset type). Currency entries, value format, and condition format are not exported to Cargo tables.

Modding Guide

Creating a Simple Currency

GUID <generate-new-guid>
Type Currency
ValueFormat "${0}"
Entries
{
    {
        Item <guid-of-1-dollar-item>
        Value 1
    }
    {
        Item <guid-of-5-dollar-item>
        Value 5
    }
    {
        Item <guid-of-20-dollar-item>
        Value 20
    }
}

This creates a three-denomination dollar currency.

Creating a Single-Denomination Currency

GUID <guid>
Type Currency
ValueFormat "{0} scrap"
Entries
{
    {
        Item <guid-of-scrap-item>
        Value 1
    }
}

A scrap-based currency with one denomination. All transactions use scrap items directly. No change-making is possible — overpayment will always result in lost value.

Hiding a Denomination from Vendor Menu

Entries
{
    {
        Item <guid-of-100-dollar-stack>
        Value 100
        Is_Visible_In_Vendor_Menu false
    }
}

The $100 stack item can be used for payment but doesn't appear in the vendor's accepted currencies list. This prevents UI clutter when a bulk-stack item represents the same currency as smaller denominations.

Assigning Currency to a Vendor

Vendors reference a ItemCurrencyAsset by GUID in their .dat:

Currency <currency-asset-guid>

All buy/sell transactions for that vendor use the specified currency.

Common Pitfalls

  1. Single-denomination without small-unit fallback: A currency with only $20 bills cannot make change. Players buying $5 items will lose $15 per transaction (pay $20, get $0 refund). Always include a $1 denomination for granularity.

  2. Zero-value entry: An entry with Value 0 contributes nothing to wealth calculations but still appears in the entries array. grantValue skips it (0 < 1 guard), spendValue tries to use it (ceiling division produces large numbers). Zero-value entries are silently buggy — they appear to work but cause unintended behavior.

  3. Missing Item reference: If a currency entry's Item GUID doesn't resolve, entry.item.Find() returns null. Both grantValue and spendValue skip null entries with continue. The currency effectively has fewer denominations than configured.

  4. Unsorted entries in .dat: The ItemCurrencyComparer sort happens during PopulateAsset, so entries do not need to be sorted in the .dat. However, for readability, sort them by value ascending in the .dat as well.

  5. Visibility flag misunderstanding: isVisibleInVendorMenu = false only affects UI display, not functionality. The entry can still be used for all transactions. Modders sometimes set it thinking it disables the entry entirely — it only hides it from the vendor menu.

  6. Value format without {0}: A ValueFormat that doesn't contain {0} won't display the actual value. "Dollars" would display as literally "Dollars" for every amount. Always include {0} in the format string.

  7. Currency not assigned to vendor: A currency asset without a vendor referencing it has no effect. Vendors use their Currency field to link to the currency asset. Without this link, the vendor uses a default currency or none at all.