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
└── ItemCurrencyAssetItemCurrencyAsset 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:
| valueFormat | Display 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:
grantValueiterates HIGHEST to LOWEST (to grant the fewest items).spendValueiterates 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
- Iterate all entries (denominations).
- For each entry, resolve the
AssetReference<ItemAsset>to an actualItemAsset. - If the item asset doesn't exist (null), skip the entry.
- Search the player's inventory for all items matching that asset:
FindItemsByAssetsearches all inventory pages.- The
falseparameter means "don't include equipped clothing pockets" (or similar filtering). - The
trueparameter means "include items in the hotbar" (or similar inclusion).
- For each matching inventory slot, add
item.amount * entry.valueto the total. - 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 = 48If 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)
- If
requiredValue < 1, do nothing (can't grant 0 or negative value). - Iterate entries from HIGHEST value to LOWEST (
entries.Length - 1down to 0). - 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).
- Calculate the integer number of items needed:
requiredValue / entry.value(floor division). - Force-give that many items to the player's inventory via
ItemTool.tryForceGiveItem. - Subtract the granted value from
requiredValue. - If
requiredValuereaches 0, return early. - If the loop exhausts all entries and
requiredValueis 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]:
- Check $20:
13 < 20→ skip. - Check $5:
13 / 5 = 2→ grant 2× $5 ($10).requiredValueremaining: 3. - Check $1:
3 / 1 = 3→ grant 3× $1 ($3).requiredValueremaining: 0. - Done.
With entries [5, 20] only (no $1):
- Check $20: skip.
- Check $5:
13 / 5 = 2→ grant 2× $5 ($10).requiredValueremaining: 3. - Loop ends (no more entries).
requiredValueis 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)
- Affordability check: Call
canAfford(player, requiredValue). If the player can't afford it, return false immediately. - Iterate entries LOWEST to HIGHEST: Iterate
entriesforward (index 0 toentries.Length - 1). This spends the smallest denominations first, leaving larger bills for change-making flexibility. - Calculate ideal amount: For each entry, calculate how many items of this denomination would be needed to cover the remaining value:The ceiling division
valueRemaining = requiredValue - spentValue idealAmount = ceil(valueRemaining / entry.value)((valueRemaining - 1) / entry.value) + 1ensures we take enough to cover or exceed the remaining value. For example,valueRemaining = 35,entry.value = 20→idealAmount = ((34) / 20) + 1 = 1 + 1 = 2(two $20 bills = $40, enough to cover $35). - Search inventory: Find all items matching this denomination.
- Delete items: For each matching inventory slot, call
DeleteAmountto remove up toidealAmountitems. Track how many items were deleted and how much value was spent. - Check if done: If
spentValue >= requiredValue, break the outer loop. - Refund: If
spentValue > requiredValue, the player overpaid. Calculate the difference and callgrantValueto refund it.
Change-Making (Refund)
The refund step handles overpayment. Example: player needs to pay $35, has 2× $20 bills:
- Iterate $1 bills: no $1 bills.
spentValue = 0. - Iterate $5 bills: no $5 bills.
spentValue = 0. - Iterate $20 bills:
idealAmount = ceil((35 - 0) / 20) = ceil(1.75) = 2. Delete 2× $20.spentValue = 40. spentValue (40) >= requiredValue (35)→ break.- Refund:
valueDue = 40 - 35 = 5. CallgrantValue(player, 5). 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
Iteminstance 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:
| valueRemaining | entry.value | Calculation | Result |
|---|---|---|---|
| 35 | 20 | (34 / 20) + 1 = 1 + 1 | 2 |
| 40 | 20 | (39 / 20) + 1 = 1 + 1 | 2 |
| 20 | 20 | (19 / 20) + 1 = 0 + 1 | 1 |
| 5 | 20 | (4 / 20) + 1 = 0 + 1 | 1 |
| 0 | 20 | (4294967295 / 20) + 1 | Overflow! |
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
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.
Zero-value entry: An entry with
Value 0contributes nothing to wealth calculations but still appears in the entries array.grantValueskips it (0 < 1 guard),spendValuetries to use it (ceiling division produces large numbers). Zero-value entries are silently buggy — they appear to work but cause unintended behavior.Missing Item reference: If a currency entry's
ItemGUID doesn't resolve,entry.item.Find()returns null. BothgrantValueandspendValueskip null entries withcontinue. The currency effectively has fewer denominations than configured.Unsorted entries in .dat: The
ItemCurrencyComparersort happens duringPopulateAsset, so entries do not need to be sorted in the .dat. However, for readability, sort them by value ascending in the .dat as well.Visibility flag misunderstanding:
isVisibleInVendorMenu = falseonly 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.Value format without {0}: A
ValueFormatthat 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.Currency not assigned to vendor: A currency asset without a vendor referencing it has no effect. Vendors use their
Currencyfield to link to the currency asset. Without this link, the vendor uses a default currency or none at all.
