@idosgames/mcp 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterLevelDefinition`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterLevelDefinition (ranks)](#characterleveldefinition-ranks)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<\n string,\n { Levels?: Record<string, CharacterLevelDefinition> }\n >;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n PriceOptions?: Record<string, PriceOption>; // empty/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n Levels?: Record<string, CharacterLevelDefinition>; // \"1\",\"2\",... -> rank\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `PriceOptions` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `PriceOptions` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n## Prices are `PriceOptions`, not a single cost\n\nEvery price in this module is a **dictionary of payment options** (`PriceOptions`), keyed by\n`OptionID`. One option = one way to pay; the entries inside an option's `Cost` are charged\ntogether. Pick one with `SelectedOptionID`, or omit it and the server takes the first option\navailable on the caller's platform — which is why a single-price character needs no client change.\n\nRender options through `client.checkout.availableOptions(...)`: an option may be restricted to\nsome platforms (`AllowedPlatforms`), and an option whose `Cost` holds a `Purchase` entry is paid\nwith a **store receipt**, not from balances. Unlock accepts store payment; stat and rank upgrades\ndo not — their price grows by a formula per level, and a store SKU is a fixed tier. See the\n`checkout-system` skill.\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n PriceOptions: Record<string, PriceOption>; // ways to pay for level 1 (must be non-empty)\n CostScalingFactor: number; // linear cost growth per level\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n StatScalingFactor: number; // flat value added per stat level (additive, not a %)\n CharacterLevelScalingFactor: number; // fractional per-rank value growth (multiplicative)\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nFormulas (the backend applies these; use them for previews):\n\n- **Cost of level `N`** (N = target level, 1-based): each entry's base `Amount`\n scaled by `Amount * (1 + CostScalingFactor * (N - 1))`, rounded to the nearest\n whole amount.\n- **Effective max level** = `floor(MaxLevel * StatMaxLevelMultiplier)` where the\n multiplier comes from the character's _current_ rank\n (`CharacterLevelDefinition.StatMaxLevelMultiplier`, default 1). This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** =\n `(BaseStatValue + N * StatScalingFactor) * (1 + CharacterLevelScalingFactor * (R - 1))`.\n The stat part grows **additively** (`N = 0` before the first upgrade, so the\n base value is what a fresh character has); the rank part multiplicatively.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\nAn option's `Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterLevelDefinition (ranks)\n\nConfig for one character Level/rank. Key in `Levels` is the level number as a\nstring (`\"1\"`, `\"2\"`, ...). Level `0` = uninitialized, has no config.\n\n```ts\ninterface CharacterLevelDefinition {\n Level: number;\n PriceOptions?: Record<string, PriceOption>; // ways to pay for this level; empty/absent = free\n GlobalStatMultiplier?: number; // multiplies the character's stat values (and thus Power) at this rank (1.0 = none)\n StatMaxLevelMultiplier?: number; // raises every stat's cap at this rank\n AssetPaths?: Record<string, string>;\n}\n```\n\n`upgradeCharacterLevel` moves the character from its current level to the next\none and charges the selected option of that level's `PriceOptions`.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the rank's `GlobalStatMultiplier`;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
8
+ "content": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterRankLadder`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterRankLadder (ranks)](#characterrankladder-the-rank-ladder)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<string, { RankLadder?: CharacterRankLadder }>;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n PriceOptions?: Record<string, PriceOption>; // empty/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n RankLadder?: CharacterRankLadder; // how many ranks and how the price grows\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `PriceOptions` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `PriceOptions` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n## Prices are `PriceOptions`, not a single cost\n\nEvery price in this module is a **dictionary of payment options** (`PriceOptions`), keyed by\n`OptionID`. One option = one way to pay; the entries inside an option's `Cost` are charged\ntogether. Pick one with `SelectedOptionID`, or omit it and the server takes the first option\navailable on the caller's platform — which is why a single-price character needs no client change.\n\nRender options through `client.checkout.availableOptions(...)`: an option may be restricted to\nsome platforms (`AllowedPlatforms`), and an option whose `Cost` holds a `Purchase` entry is paid\nwith a **store receipt**, not from balances. Unlock accepts store payment; stat and rank upgrades\ndo not — their price grows by a formula per level, and a store SKU is a fixed tier. See the\n`checkout-system` skill.\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n PriceOptions: Record<string, PriceOption>; // ways to pay for level 1 (must be non-empty)\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n ValueCurve?: ScalarCurveSpec; // growth over the stat's OWN level; step from 0\n RankCurve?: ScalarCurveSpec; // growth over the character's rank; step from 1\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nEvery growth here is a `ScalarCurveSpec` — the platform's shared curve. **An unset\ncurve is the identity**, so a stat with no `ValueCurve` simply does not grow when\nupgraded; there is no field whose neutral value is `1`. The shapes are `Flat`,\n`PerStep` (units per step), `PerStepRate` (share of the base per step), `Geometric`,\n`Table`, `TableAbsolute`. See the `curve-system` notes in the core package\n(`evaluateCurve`, exported from `@idosgames/core`) rather than re-deriving them.\n\nFormulas (the backend applies these; use `evaluateCurve` for previews):\n\n- **Cost of level `N`** (N = target level): each entry's base `Amount` put through\n `CostCurve` with `firstStep = 1`, rounded **UP**, once, at the end.\n- **Effective max level** = `MaxLevel` put through the character's\n `StatMaxLevelCurve` at its current rank (`firstStep = 1`), rounded up. This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** = `ValueCurve(BaseStatValue, N)`\n with **`firstStep = 0`**, then `RankCurve(thatValue, R)` with `firstStep = 1`, then the\n character-wide `RankStatCurve(R)`.\n ⚠ The stat level counts from **0** — a stat the player never upgraded is level 0 and is\n worth the plain base — while ranks, prices and item levels count from 1. Using the wrong\n first step shifts every value one step along the curve.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\nAn option's `Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterRankLadder (the rank ladder)\n\n```ts\ninterface CharacterRankLadder {\n MaxRank?: number; // how many ranks in total; empty = no ceiling\n FirstPaidRank?: number; // ranks below it are free; empty = 1\n PriceOptions?: Record<string, PriceOption>; // price of the first PAID rank\n CostCurve?: ScalarCurveSpec; // price growth; step = the rank, FirstPaidRank = first step\n}\n```\n\nLives on `CharacterDefinition.RankLadder`, or on the levels preset (`LevelsPreset.RankLadder`)\nwhen the character binds one. **It is the only source of rank prices** — there is no per-rank\ndictionary, and a character without a ladder cannot be ranked up at all.\n\nThe inline ladder **replaces** the preset one whole rather than merging field by field: it is one\nobject (\"how many ranks and how the price grows\"), and taking the ceiling from one place and the\nprice from another has no meaning.\n\n⚠ **A non-uniform ladder is expressed by the curve shape**, not by per-rank entries: `Table` and\n`TableAbsolute` place a multiplier per rank. What a ladder cannot express is a different price\nCOMPOSITION per rank (rank 5 paid in a special item, the rest in gold) — the composition is fixed\nby `PriceOptions` and the curve only scales the amount.\n\n⚠ **`FirstPaidRank` is how a free activation is expressed.** Level `0` means the character exists\nbut is not activated; in most titles the 0 → 1 step is free because the character itself is paid\nfor in `Unlock.PriceOptions`. Ranks below `FirstPaidRank` cost nothing and do **not** stop the\nclimb. The curve starts counting at `FirstPaidRank`, so the base is the price of the first rank\nthat actually costs something.\n\n`upgradeCharacterLevel` moves the character from its current level to the next one and charges the\nselected option of the ladder's `PriceOptions`, scaled by `CostCurve` at that rank.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the character's `RankStatCurve` at the current rank;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Collection data model — reference\n\nFull shape of the config (`CollectionDefinitions`), player state\n(`UserCollectionState`), the pack/chest reward mechanics, and the trade-offer\nlifecycle. Config types are **strictly typed in the SDK** — `CollectionDefinitions`\nand every nested block are exported from `@idosgames/core`. Every schema keeps\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CollectionDefinitions](#config-collectiondefinitions)\n- [CollectionDefinition / Sets / Collectibles](#collectiondefinition--sets--collectibles)\n- [PackTypes and CollectionChests (reward slots + pity)](#packtypes-and-collectionchests)\n- [Duplicate conversion](#duplicate-conversion)\n- [SpecialTradeEvents](#specialtradeevents)\n- [Player state: UserCollectionState](#player-state-usercollectionstate)\n- [Season-linked wipe](#season-linked-wipe)\n- [Trade offers](#trade-offers)\n- [Responses](#responses)\n\n---\n\n## Config: CollectionDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CollectionDefinitions>(\"Collection\")`.\n\n```ts\ninterface CollectionDefinitions {\n Collections?: Record<string, CollectionDefinition> | null; // key = CollectionID\n PackTypes?: Record<string, CollectionPackTypeDefinition> | null; // key = PackTypeID\n CollectionChests?: CollectionChestDefinition[] | null;\n DuplicateConversions?: DuplicateCollectionCurrencyConversion[] | null;\n DailyTradeLimit?: number | null;\n CollectibleJokerCatalogID?: string | null;\n CollectibleJokerItemID?: string | null;\n SpecialTradeEvents?: SpecialTradeEventDefinition[] | null;\n}\n```\n\n`DailyTradeLimit` bounds `sendTradeOffer` calls per calendar day (tracked by\n`UserCollectionState.DailyTradesSent` / `DailyTradesResetDate`, reset at UTC\nmidnight); backend default is **5/day** if the title doesn't set it.\n`CollectibleJokerItemID` (optionally scoped by `CollectibleJokerCatalogID`) is\nthe item burned by `useCollectibleJoker` — grant this item to players through\nthe Item/Store/Lootbox modules; the Collection module only consumes it. It's a\nnormal `InventoryV2.Items` item and does not burn on a season wipe, so players\ncan bank Jokers across seasons.\n\n---\n\n## CollectionDefinition / Sets / Collectibles\n\nThree-level hierarchy: Collection → Set → Collectible.\n\n```ts\ninterface CollectionDefinition {\n CollectionID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n SeasonChainID?: string; // links this collection to a season chain, if any\n Sets?: CollectionSetDefinition[];\n GrandPrize?: ResourceGrant; // claimed once via claimGrandPrize()\n}\n\ninterface CollectionSetDefinition {\n SetID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n SortOrder?: number;\n Collectibles?: CollectibleDefinition[];\n SetCompletionReward?: ResourceGrant; // claimed once via claimSetReward()\n}\n\ninterface CollectibleDefinition {\n CollectibleID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Rarity?: number;\n HasSpecialVersion?: boolean; // a rarer \"Special\" variant exists for this id\n SortOrder?: number;\n}\n```\n\nA Collection is \"completed\" (`IsCollectionCompleted`) once every Set inside it\nis completed; a Set is completed once every listed Collectible has been\nobtained at least once (`OwnedCollectibles[id] >= 1`). Owning duplicates past\n1 does not grant anything further directly — see\n[Duplicate conversion](#duplicate-conversion).\n\n---\n\n## PackTypes and CollectionChests\n\nBoth are openable reward containers priced differently: Packs cost the shared\n`ResourceConsume` type (currency/items/event tokens); Chests are priced purely\nin `CollectionCurrencyCost` (the module's own soft currency, earned from\nduplicates).\n\n```ts\ninterface CollectionPackTypeDefinition {\n PackTypeID?: string;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; the selected one is charged by openPack(), required non-empty or the open is rejected\n BonusRewardSlots?: LootboxRewardSlot[]; // extra non-collectible rewards\n PityRules?: LootboxPityRule[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectibleCount?: number; // how many Collectibles this pack grants (backend default 3)\n GuaranteedMinRarity?: number; // backend default 1\n GuaranteeMaxRarity?: boolean; // backend default false\n RarityWeights?: Record<string, number>; // rarity id (as string \"1\"..\"5\") -> drop weight\n ColorTier?: number; // 1=Green,2=Blue,3=Orange,4=Purple; backend default 1\n}\n\ninterface CollectionChestDefinition {\n CollectionChestID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectionCurrencyCost?: number;\n MinCollectibleCount?: number; // backend default 1\n MaxCollectibleCount?: number; // backend default 2\n GuaranteedMinRarity?: number; // backend default 2\n BonusRewardSlots?: LootboxRewardSlot[];\n PityRules?: LootboxPityRule[];\n Tier?: number; // 1=Bronze,2=Silver,3=Gold; backend default 1\n}\n```\n\n`BonusRewardSlots` / `PityRules` reuse the shared reward-slot primitives from\n`_shared/RewardSlotModels.ts` (the same ones the Lootbox module uses — see\n[lootbox-system](../../lootbox-system/SKILL.md) if you need the full\nslot/pool/pity mechanics):\n\n```ts\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only; no Consume side\n Weight?: number;\n AmountRange?: { Min?: number; Max?: number };\n}\ninterface LootboxRewardSlot {\n SlotID?: string;\n MinRolls?: number; // independent rolls to make over Pool\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxPityRule {\n RuleID?: string;\n Threshold?: number; // every Nth open without a qualifying pull, force one\n Pool?: LootboxRewardRoll[];\n}\n```\n\n**Pack roll (`OpenPack`, per Collectible slot, `Collection.cs`\n`RollCollectiblesForPack`):** slot 0 gets the pack's guarantee — if\n`GuaranteeMaxRarity` is true it forces rarity 5, otherwise it weighted-picks\nfrom `RarityWeights` with a floor of `GuaranteedMinRarity`; every other slot\nweighted-picks with a floor of rarity 1. The weighted pick\n(`PickRarityWeighted`) filters `RarityWeights` entries to `rarity >= floor`,\nsums their weights, and rolls uniformly in `[0, total)` via `SecureRandom`; if\nno candidate matches the exact rarity it widens to `>= targetRarity`, then\nfalls back to the full Collectible pool. A picked Collectible that already has\n`HasSpecialVersion: true` additionally has a **flat 5% chance** to be granted\nas its Special version instead of the normal one (`SecureRandom.Next(0,100) <\n5`) — this 5% is hardcoded, not title-configurable.\n\n**Chest roll (`OpenCollectionChest`, `RollCollectiblesForCollectionChest`):**\npicks a random Collectible count uniformly in\n`[MinCollectibleCount, MaxCollectibleCount]`, then for each pick filters the\npool to `Rarity >= GuaranteedMinRarity` (falling back to the full pool if that\nfilter is empty) and picks uniformly at random. Chests never roll Special\nversions.\n\n**Duplicate detection happens per-roll, in-order, within the same open**: the\n\"already owned\" check adds in any Collectibles already granted earlier in the\n_same_ pack/chest before checking the next slot, so pulling the same\nCollectible twice in one 5-slot pack correctly flags the second as a\nduplicate even though neither has hit the database yet.\n\nPity progress is tracked per rule in\n`UserCollectionState.PityCounters: Record<string, UserLootboxPityCounter>`,\nkeyed by **`\"{PackTypeID or CollectionChestID}:{RuleID}\"`** (literal colon\njoin; `CollectionPityHelpers.CounterKey`) — not by `RuleID` alone, so the same\n`RuleID` reused across two pack types tracks independently. The counter type\nis shared with the Lootbox module. Math per open (count is always 1 for\nCollection, unlike Lootbox's multi-open): `totalSteps = counter + 1`,\n`triggers = totalSteps / Threshold` (0 or 1), `newCounter = totalSteps %\nThreshold` — i.e. classic hard-pity, resets to 0 exactly on the open that\nhits the threshold. `OpenPackResponse` / `OpenCollectionChestResponse` both\ncarry `TriggeredPity: unknown[]` — the response signals _that_ pity fired\n(with `RuleID`, always `BoxIndex: 0` for Collection) but doesn't strictly\ntype the payload shape; treat it as informational (e.g. a \"pity!\" toast)\nrather than something to branch business logic on.\n\n---\n\n## Duplicate conversion\n\n```ts\ninterface DuplicateCollectionCurrencyConversion {\n Rarity?: number;\n CollectionCurrencyGranted?: number;\n}\n```\n\nWhen a pack/chest pull is a Collectible the player already owns, instead of\nstacking uselessly it auto-converts into `CollectionCurrencyGranted` (looked\nup by the pulled Collectible's `Rarity` in this list) — that's the\n`CollectionCurrencyEarned` you see on `OpenPackResponse` /\n`OpenCollectionChestResponse`, and it's what funds `openCollectionChest`. This\nis why chests exist: a way to spend \"wasted\" duplicate pulls on guaranteed\nprogress instead.\n\n**Fallback when no rule matches the rarity:** `GetCollectionCurrencyForDuplicate`\nfalls back to `rarity` itself (i.e. a rarity-3 duplicate grants 3 Collection\nCurrency) if `DuplicateConversions` has no entry for that rarity — so an\nincomplete conversion table doesn't silently grant 0, but also won't match\nwhatever curve you intended. Configure every rarity 1-5 explicitly rather than\nrelying on the fallback.\n\nA duplicate normally caps ownership at effectively 1 (the doc comment on\n`UserCollectionState.OwnedCollectibles` calls `>= 2` a rare/transient state —\nconversion is meant to be immediate) but the code path that increments it is\nplain `Dictionary` arithmetic in memory before the Mongo patch, so treat\n`OwnedCollectibles[id]` as \"0, 1, or rarely-briefly more,\" not a strict\nboolean.\n\n---\n\n## SpecialTradeEvents\n\n```ts\ninterface SpecialTradeEventDefinition {\n SpecialTradeEventID?: string;\n StartUtc?: string;\n EndUtc?: string;\n AllowedSpecialCollectibleIDs?: string[];\n SpecialTradeEventDailyTradeLimit?: number;\n}\n```\n\nSpecial-version Collectibles (`HasSpecialVersion: true` on the base\nCollectible, traded with `collectibleIsSpecial: true`) can only move via\n`sendTradeOffer` while an active event's window covers `now` **and** lists\nthat Collectible in `AllowedSpecialCollectibleIDs`. Outside any such window,\noffering a Special is rejected server-side. The event also carries its own\ndaily limit distinct from the title-wide `DailyTradeLimit`.\n\n---\n\n## Player state: UserCollectionState\n\nReturned by `getUserState()`; cached at `client.data.user.state?.Collection`.\n**Loosely typed** (`z.object({}).passthrough()` cast to the interface) —\nunlike the config side, this is not field-validated, so treat it as\nbest-effort and read defensively.\n\n```ts\ninterface UserCollectionState {\n CollectionID?: string;\n SeasonVersion?: number;\n CollectionCurrencyBalance?: number;\n TotalCollectionCurrencyEarned?: number;\n OwnedCollectibles?: Record<string, number>; // CollectibleID -> count owned\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[]; // SetIDs already claimed\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n DailyTradesSent?: number;\n DailyTradesResetDate?: string;\n PendingTradeOfferIDs?: string[];\n PityCounters?: Record<string, UserLootboxPityCounter>; // key = \"{PackTypeID|CollectionChestID}:{RuleID}\"\n}\n```\n\n`SeasonVersion` defaults to `0` when the collection isn't season-linked.\n`PityCounters` (like `OwnedCollectibles`/`OwnedSpecialCollectibles`) is a plain\ndictionary that only gains a key the first time that pool triggers — treat a\nmissing key as counter `0`, not an error.\n\n---\n\n## Season-linked wipe\n\nA `CollectionDefinition` may set `SeasonChainID` to bind itself to a season\nchain (`Season` module). Every Collection action re-derives the \"current\"\n`(activeCollectionID, SeasonVersion)` pair on each call\n(`EnsureCollectionWipedIfNeededAsync` in `Collection.cs`):\n\n- If any season chain has a `LinkedCollectionID` whose window is currently\n active (not paused), that collection is the active one, and\n `SeasonVersion = CycleIndex * 1000 + SeasonOrder` of that window.\n- Otherwise, if the title has no season-linked collection, the **first**\n collection in config-declaration order (`Collections.Keys.First()`) is used\n with `SeasonVersion = 0`.\n\nIf the player's stored `UserCollectionState.CollectionID` /\n`SeasonVersion` doesn't match, the **entire** Collection state is wiped and\nreplaced with a fresh zeroed one (new `CollectionID`, `SeasonVersion`, empty\n`OwnedCollectibles`/`OwnedSpecialCollectibles`/`ClaimedSetRewards`/\n`PendingTradeOfferIDs`, zeroed currency, `IsCollectionCompleted`/\n`GrandPrizeClaimed` reset to `false`) — this happens **lazily**, on the very\nnext Collection call the player makes after the season rolls over, not on a\nschedule. There is no dedicated wipe event; the wiped state is simply what\nthe next `getUserState()` (or any other Collection call) returns. Design\naround this: don't assume a cached `Collection` state survives across a\nsession gap without a fresh fetch, and don't build UI that depends on\n`OwnedCollectibles` persisting across a season boundary for a season-linked\ncollection.\n\n---\n\n## Trade offers\n\n```ts\ninterface CollectionTradeOfferDocument {\n OfferID: string;\n TitleID?: string;\n CollectionID?: string;\n SenderUserID?: string;\n SenderPublicData?: UserPublicDataModel; // sender's public profile snapshot\n OfferedCollectibleID?: string;\n OfferedCollectibleIsSpecial?: boolean;\n ReceiverUserID?: string;\n RequestedCollectibleID?: string; // absent = open/gift offer, no ask-back\n RequestedCollectibleIsSpecial?: boolean;\n Status?: \"Pending\" | \"Accepted\" | \"Declined\" | \"Cancelled\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RespondedAtUtc?: string;\n DeclineReason?: string;\n IsSpecialTradeEvent?: boolean;\n SpecialTradeEventID?: string;\n}\n```\n\n**Preconditions checked by `sendTradeOffer` (`Collection.cs` `SendTradeOffer`),\nin order:** `ReceiverUserID` can't equal your own `UserID` (\"Cannot trade with\nyourself\"); the receiver must be in your **Social.Accepted friends list**\n(\"Receiver must be in your friends list\" — see\n[social-system](../../social-system/SKILL.md)); if `CollectibleIsSpecial` a\nmatching active `SpecialTradeEventDefinition` must exist (\"Special\nCollectibles can only be traded during an active SpecialTradeEvent\"); the\neffective daily limit (event-specific limit if trading a Special during its\nevent window, else the title's `DailyTradeLimit`, lazily reset at UTC\nmidnight) must not already be hit (\"Daily trade limit reached (N/day)\"); you\nmust hold enough of the offered Collectible — **`>= 2`** for a normal\nCollectible (you keep one, offer the spare) or **`>= 1`** for a Special (the\nwhole thing moves, no spare kept back); and the receiver's\n`PendingTradeOfferIDs` must have fewer than **10** entries\n(`MaxPendingIncomingOffers`) — \"Receiver has too many pending trade offers.\"\nThe same `>= 2` (normal) / `>= 1` (Special) ownership check re-runs against\nthe **receiver's** balance for `RequestedCollectibleID` at `acceptTradeOffer`\ntime, since their holdings may have changed since the offer was sent.\n\nLifecycle: `sendTradeOffer` creates a document with `Status: \"Pending\"`,\n`CreatedAtUtc: now`, and `ExpiresAtUtc: now + 168h` (7 days —\n`TradeOfferExpirationHours` in `Collection.cs`, not title-configurable). The\nreceiver calls `getIncomingTradeOffers` to see it, then either\n`acceptTradeOffer` (→ `Status: \"Accepted\"`, both Collectibles swap owners; also\nrejected if `ExpiresAtUtc <= now`, \"Offer has expired\") or `declineTradeOffer`\n(→ `Status: \"Declined\"`). The sender can `cancelTradeOffer` any offer still\n`\"Pending\"` (→ `Status: \"Cancelled\"`).\n\n**`\"Expired\"` is a declared `Status` value the backend never actually\nwrites** — there is no sweep job that flips stale offers to `Expired`.\n`getIncomingTradeOffers` filters server-side to `Status == \"Pending\" &&\nExpiresAtUtc > now`, so an expired incoming offer just silently drops out of\nthat list (it doesn't surface with a distinguishable status). `getMyTradeOffers`\n(outgoing) has **no such filter** — it returns everything you've ever sent for\nthat collection (newest 20), so a lapsed offer you sent still reads\n`Status: \"Pending\"` with an `ExpiresAtUtc` in the past; compare `ExpiresAtUtc`\nagainst the current time yourself if you need to grey it out in a \"my offers\"\nlist. None of the four trade actions mutate `client.data.user` directly (no\n`Resources`, no `Collection` cache patch) — re-fetch `getUserState()` / the\noffer lists to observe the effect.\n\n---\n\n## Responses\n\n```ts\ninterface GrantedCollectible {\n CollectibleID: string;\n Rarity?: number;\n IsSpecial?: boolean;\n IsDuplicate?: boolean;\n CollectionCurrencyConverted?: number; // set when IsDuplicate\n}\n\ninterface OpenPackResponse {\n GrantedCollectibles?: GrantedCollectible[]; // full pull list (incl. duplicates)\n DuplicateCollectibles?: GrantedCollectible[]; // subset that were duplicates\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n NewlyCompletedSetIDs?: string[];\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // pack Cost debit (+ BonusRewardSlots grants)\n TriggeredPity?: unknown[];\n}\n\ninterface OpenCollectionChestResponse {\n GrantedCollectibles?: GrantedCollectible[];\n DuplicateCollectibles?: GrantedCollectible[];\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n Resources?: ResourceOperation; // CollectionCurrencyCost debit (+ bonus grants)\n TriggeredPity?: unknown[];\n}\n\ninterface UseCollectibleJokerResponse {\n GrantedCollectibleID?: string;\n NewlyCompletedSetID?: string;\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // Joker item consumed\n}\n\ninterface ClaimSetRewardResponse {\n SetID: string;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimGrandPrizeResponse {\n Resources?: ResourceOperation;\n}\n\ninterface SendTradeOfferResponse {\n OfferID: string;\n ExpiresAtUtc?: string;\n Resources?: ResourceOperation; // usually absent; trading has no inherent cost\n}\n\ninterface AcceptTradeOfferResponse {\n OfferID: string;\n ReceivedCollectibleID?: string;\n ReceivedIsSpecial?: boolean;\n SentCollectibleID?: string;\n SentCollectibleIsSpecial?: boolean;\n Transfer?: unknown; // server-internal transfer record, not strictly typed\n}\n```\n\n`ClaimSetRewardsBatchResponse` is `BatchItemResult<ClaimSetRewardResponse>[]`\n— see the shared `BatchItemResult<T>` shape\n(`_shared/BatchModels.ts`): `{ Id, Success, Error?, Data? }` per item, one\natomic charge across the whole batch.\n"
8
+ "content": "# Collection data model — reference\n\nFull shape of the config (`CollectionDefinitions`), player state\n(`UserCollectionState`), the pack/chest reward mechanics, and the trade-offer\nlifecycle. Config types are **strictly typed in the SDK** — `CollectionDefinitions`\nand every nested block are exported from `@idosgames/core`. Every schema keeps\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CollectionDefinitions](#config-collectiondefinitions)\n- [CollectionDefinition / Sets / Collectibles](#collectiondefinition--sets--collectibles)\n- [PackTypes and CollectionChests (reward slots + pity)](#packtypes-and-collectionchests)\n- [Duplicate conversion](#duplicate-conversion)\n- [SpecialTradeEvents](#specialtradeevents)\n- [Player state: UserCollectionState](#player-state-usercollectionstate)\n- [Season-linked wipe](#season-linked-wipe)\n- [Trade offers](#trade-offers)\n- [Responses](#responses)\n\n---\n\n## Config: CollectionDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CollectionDefinitions>(\"Collection\")`.\n\n```ts\ninterface CollectionDefinitions {\n Collections?: Record<string, CollectionDefinition> | null; // key = CollectionID\n PackTypes?: Record<string, CollectionPackTypeDefinition> | null; // key = PackTypeID\n CollectionChests?: CollectionChestDefinition[] | null;\n DuplicateConversions?: DuplicateCollectionCurrencyConversion[] | null;\n DailyTradeLimit?: number | null;\n CollectibleJokerCatalogID?: string | null;\n CollectibleJokerItemID?: string | null;\n SpecialTradeEvents?: SpecialTradeEventDefinition[] | null;\n}\n```\n\n`DailyTradeLimit` bounds `sendTradeOffer` calls per calendar day (tracked by\n`UserCollectionState.DailyTradesSent` / `DailyTradesResetDate`, reset at UTC\nmidnight); backend default is **5/day** if the title doesn't set it.\n`CollectibleJokerItemID` (optionally scoped by `CollectibleJokerCatalogID`) is\nthe item burned by `useCollectibleJoker` — grant this item to players through\nthe Item/Store/Lootbox modules; the Collection module only consumes it. It's a\nnormal `InventoryV2.Items` item and does not burn on a season wipe, so players\ncan bank Jokers across seasons.\n\n---\n\n## CollectionDefinition / Sets / Collectibles\n\nThree-level hierarchy: Collection → Set → Collectible.\n\n```ts\ninterface CollectionDefinition {\n CollectionID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n SeasonChainID?: string; // links this collection to a season chain, if any\n Sets?: CollectionSetDefinition[];\n GrandPrize?: ResourceGrant; // claimed once via claimGrandPrize()\n}\n\ninterface CollectionSetDefinition {\n SetID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n SortOrder?: number;\n Collectibles?: CollectibleDefinition[];\n SetCompletionReward?: ResourceGrant; // claimed once via claimSetReward()\n}\n\ninterface CollectibleDefinition {\n CollectibleID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Rarity?: number;\n HasSpecialVersion?: boolean; // a rarer \"Special\" variant exists for this id\n SortOrder?: number;\n}\n```\n\nA Collection is \"completed\" (`IsCollectionCompleted`) once every Set inside it\nis completed; a Set is completed once every listed Collectible has been\nobtained at least once (`OwnedCollectibles[id] >= 1`). Owning duplicates past\n1 does not grant anything further directly — see\n[Duplicate conversion](#duplicate-conversion).\n\n---\n\n## PackTypes and CollectionChests\n\nBoth are openable reward containers priced differently: Packs cost the shared\n`ResourceConsume` type (currency/items/event tokens); Chests are priced purely\nin `CollectionCurrencyCost` (the module's own soft currency, earned from\nduplicates).\n\n```ts\ninterface CollectionPackTypeDefinition {\n PackTypeID?: string;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; the selected one is charged by openPack(), required non-empty or the open is rejected\n BonusRewardSlots?: LootboxRewardSlot[]; // extra non-collectible rewards\n PityRules?: LootboxPityRule[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectibleCount?: number; // how many Collectibles this pack grants (backend default 3)\n GuaranteedMinRarity?: number; // backend default 1\n GuaranteeMaxRarity?: boolean; // backend default false\n RarityWeights?: Record<string, number>; // rarity id (as string \"1\"..\"5\") -> drop weight\n ColorTier?: number; // 1=Green,2=Blue,3=Orange,4=Purple; backend default 1\n}\n\ninterface CollectionChestDefinition {\n CollectionChestID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectionCurrencyCost?: number;\n MinCollectibleCount?: number; // backend default 1\n MaxCollectibleCount?: number; // backend default 2\n GuaranteedMinRarity?: number; // backend default 2\n BonusRewardSlots?: LootboxRewardSlot[];\n PityRules?: LootboxPityRule[];\n Tier?: number; // 1=Bronze,2=Silver,3=Gold; backend default 1\n}\n```\n\n`BonusRewardSlots` / `PityRules` reuse the shared reward-slot primitives from\n`_shared/RewardSlotModels.ts` (the same ones the Lootbox module uses — see\n[lootbox-system](../../lootbox-system/SKILL.md) if you need the full\nslot/pool/pity mechanics):\n\n```ts\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only; no Consume side\n Weight?: number;\n AmountRange?: { Min?: number; Max?: number };\n}\ninterface LootboxRewardSlot {\n SlotID?: string;\n MinRolls?: number; // independent rolls to make over Pool\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxPityRule {\n RuleID?: string;\n Threshold?: number; // every Nth open without a qualifying pull, force one\n Pool?: LootboxRewardRoll[];\n}\n```\n\n**Pack roll (`OpenPack`, per Collectible slot, `Collection.cs`\n`RollCollectiblesForPack`):** slot 0 gets the pack's guarantee — if\n`GuaranteeMaxRarity` is true it forces rarity 5, otherwise it weighted-picks\nfrom `RarityWeights` with a floor of `GuaranteedMinRarity`; every other slot\nweighted-picks with a floor of rarity 1. The weighted pick\n(`PickRarityWeighted`) filters `RarityWeights` entries to `rarity >= floor`,\nsums their weights, and rolls uniformly in `[0, total)` via `SecureRandom`; if\nno candidate matches the exact rarity it widens to `>= targetRarity`, then\nfalls back to the full Collectible pool. A picked Collectible that already has\n`HasSpecialVersion: true` additionally has a **flat 5% chance** to be granted\nas its Special version instead of the normal one (`SecureRandom.Next(0,100) <\n5`) — this 5% is hardcoded, not title-configurable.\n\n**Chest roll (`OpenCollectionChest`, `RollCollectiblesForCollectionChest`):**\npicks a random Collectible count uniformly in\n`[MinCollectibleCount, MaxCollectibleCount]`, then for each pick filters the\npool to `Rarity >= GuaranteedMinRarity` (falling back to the full pool if that\nfilter is empty) and picks uniformly at random. Chests never roll Special\nversions.\n\n**Duplicate detection happens per-roll, in-order, within the same open**: the\n\"already owned\" check adds in any Collectibles already granted earlier in the\n_same_ pack/chest before checking the next slot, so pulling the same\nCollectible twice in one 5-slot pack correctly flags the second as a\nduplicate even though neither has hit the database yet.\n\nPity progress is tracked per rule in\n`UserCollectionState.PityCounters: Record<string, UserLootboxPityCounter>`,\nkeyed by **`\"{PackTypeID or CollectionChestID}:{RuleID}\"`** (literal colon\njoin; `CollectionPityHelpers.CounterKey`) — not by `RuleID` alone, so the same\n`RuleID` reused across two pack types tracks independently. The counter type\nis shared with the Lootbox module. Math per open (count is always 1 for\nCollection, unlike Lootbox's multi-open): `totalSteps = counter + 1`,\n`triggers = totalSteps / Threshold` (0 or 1), `newCounter = totalSteps %\nThreshold` — i.e. classic hard-pity, resets to 0 exactly on the open that\nhits the threshold. `OpenPackResponse` / `OpenCollectionChestResponse` both\ncarry `TriggeredPity: unknown[]` — the response signals _that_ pity fired\n(with `RuleID`, always `BoxIndex: 0` for Collection) but doesn't strictly\ntype the payload shape; treat it as informational (e.g. a \"pity!\" toast)\nrather than something to branch business logic on.\n\n---\n\n## Duplicate conversion\n\n```ts\ninterface DuplicateCollectionCurrencyConversion {\n Rarity?: number;\n CollectionCurrencyGranted?: number;\n}\n```\n\nWhen a pack/chest pull is a Collectible the player already owns, instead of\nstacking uselessly it auto-converts into `CollectionCurrencyGranted` (looked\nup by the pulled Collectible's `Rarity` in this list) — that's the\n`CollectionCurrencyEarned` you see on `OpenPackResponse` /\n`OpenCollectionChestResponse`, and it's what funds `openCollectionChest`. This\nis why chests exist: a way to spend \"wasted\" duplicate pulls on guaranteed\nprogress instead.\n\n**Fallback when no rule matches the rarity:** `GetCollectionCurrencyForDuplicate`\nfalls back to `rarity` itself (i.e. a rarity-3 duplicate grants 3 Collection\nCurrency) if `DuplicateConversions` has no entry for that rarity — so an\nincomplete conversion table doesn't silently grant 0, but also won't match\nwhatever curve you intended. Configure every rarity 1-5 explicitly rather than\nrelying on the fallback.\n\nA duplicate normally caps ownership at effectively 1 (the doc comment on\n`UserCollectionState.OwnedCollectibles` calls `>= 2` a rare/transient state —\nconversion is meant to be immediate) but the code path that increments it is\nplain `Dictionary` arithmetic in memory before the Mongo patch, so treat\n`OwnedCollectibles[id]` as \"0, 1, or rarely-briefly more,\" not a strict\nboolean.\n\n---\n\n## SpecialTradeEvents\n\n```ts\ninterface SpecialTradeEventDefinition {\n SpecialTradeEventID?: string;\n StartUtc?: string;\n EndUtc?: string;\n AllowedSpecialCollectibleIDs?: string[];\n SpecialTradeEventDailyTradeLimit?: number;\n}\n```\n\nSpecial-version Collectibles (`HasSpecialVersion: true` on the base\nCollectible, traded with `collectibleIsSpecial: true`) can only move via\n`sendTradeOffer` while an active event's window covers `now` **and** lists\nthat Collectible in `AllowedSpecialCollectibleIDs`. Outside any such window,\noffering a Special is rejected server-side. The event also carries its own\ndaily limit distinct from the title-wide `DailyTradeLimit`.\n\n---\n\n## Player state: UserCollectionState\n\nReturned by `getUserState()`; cached at `client.data.user.state?.Collection`.\n**Loosely typed** (`z.object({}).passthrough()` cast to the interface) —\nunlike the config side, this is not field-validated, so treat it as\nbest-effort and read defensively.\n\n```ts\ninterface UserCollectionState {\n CollectionID?: string;\n SeasonVersion?: number;\n CollectionCurrencyBalance?: number;\n TotalCollectionCurrencyEarned?: number;\n OwnedCollectibles?: Record<string, number>; // CollectibleID -> count owned\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[]; // SetIDs already claimed\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n DailyTradesSent?: number;\n DailyTradesResetDate?: string;\n PendingTradeOfferIDs?: string[];\n PityCounters?: Record<string, UserLootboxPityCounter>; // key = \"{PackTypeID|CollectionChestID}:{RuleID}\"\n}\n```\n\n`SeasonVersion` defaults to `0` when the collection isn't season-linked.\n`PityCounters` (like `OwnedCollectibles`/`OwnedSpecialCollectibles`) is a plain\ndictionary that only gains a key the first time that pool triggers — treat a\nmissing key as counter `0`, not an error.\n\n---\n\n## Season-linked wipe\n\nA `CollectionDefinition` may set `SeasonChainID` to bind itself to a season\nchain (`Season` module). Every Collection action re-derives the \"current\"\n`(activeCollectionID, SeasonVersion)` pair on each call\n(`EnsureCollectionWipedIfNeededAsync` in `Collection.cs`):\n\n- If any season chain has a `LinkedCollectionID` whose window is currently\n active (not paused), that collection is the active one, and\n `SeasonVersion = CycleIndex * 1000 + SeasonOrder` of that window.\n- Otherwise, if the title has no season-linked collection, the **first**\n collection in config-declaration order (`Collections.Keys.First()`) is used\n with `SeasonVersion = 0`.\n\nIf the player's stored `UserCollectionState.CollectionID` /\n`SeasonVersion` doesn't match, the **entire** Collection state is wiped and\nreplaced with a fresh zeroed one (new `CollectionID`, `SeasonVersion`, empty\n`OwnedCollectibles`/`OwnedSpecialCollectibles`/`ClaimedSetRewards`/\n`PendingTradeOfferIDs`, zeroed currency, `IsCollectionCompleted`/\n`GrandPrizeClaimed` reset to `false`) — this happens **lazily**, on the very\nnext Collection call the player makes after the season rolls over, not on a\nschedule. There is no dedicated wipe event; the wiped state is simply what\nthe next `getUserState()` (or any other Collection call) returns. Design\naround this: don't assume a cached `Collection` state survives across a\nsession gap without a fresh fetch, and don't build UI that depends on\n`OwnedCollectibles` persisting across a season boundary for a season-linked\ncollection.\n\n---\n\n## Trade offers\n\n```ts\ninterface CollectionTradeOfferDocument {\n OfferID: string;\n TitleID?: string;\n CollectionID?: string;\n SenderUserID?: string;\n SenderPublicData?: UserPublicDataModel; // sender's public profile snapshot\n OfferedCollectibleID?: string;\n OfferedCollectibleIsSpecial?: boolean;\n ReceiverUserID?: string;\n RequestedCollectibleID?: string; // absent = open/gift offer, no ask-back\n RequestedCollectibleIsSpecial?: boolean;\n Status?: \"Pending\" | \"Accepted\" | \"Declined\" | \"Cancelled\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RespondedAtUtc?: string;\n DeclineReason?: string;\n IsSpecialTradeEvent?: boolean;\n SpecialTradeEventID?: string;\n}\n```\n\n**Preconditions checked by `sendTradeOffer` (`Collection.cs` `SendTradeOffer`),\nin order:** `ReceiverUserID` can't equal your own `UserID` (\"Cannot trade with\nyourself\"); the receiver must be **your friend** — the server checks the social\ngraph directly (a point lookup on the friendship edge), NOT a list inside the\nplayer document, so it is authoritative even if your local\n`Social.Accepted` cache was never loaded (\"Receiver must be in your friends\nlist\" — see [social-system](../../social-system/SKILL.md)); if `CollectibleIsSpecial` a\nmatching active `SpecialTradeEventDefinition` must exist (\"Special\nCollectibles can only be traded during an active SpecialTradeEvent\"); the\neffective daily limit (event-specific limit if trading a Special during its\nevent window, else the title's `DailyTradeLimit`, lazily reset at UTC\nmidnight) must not already be hit (\"Daily trade limit reached (N/day)\"); you\nmust hold enough of the offered Collectible — **`>= 2`** for a normal\nCollectible (you keep one, offer the spare) or **`>= 1`** for a Special (the\nwhole thing moves, no spare kept back); and the receiver's\n`PendingTradeOfferIDs` must have fewer than **10** entries\n(`MaxPendingIncomingOffers`) — \"Receiver has too many pending trade offers.\"\nThe same `>= 2` (normal) / `>= 1` (Special) ownership check re-runs against\nthe **receiver's** balance for `RequestedCollectibleID` at `acceptTradeOffer`\ntime, since their holdings may have changed since the offer was sent.\n\nLifecycle: `sendTradeOffer` creates a document with `Status: \"Pending\"`,\n`CreatedAtUtc: now`, and `ExpiresAtUtc: now + 168h` (7 days —\n`TradeOfferExpirationHours` in `Collection.cs`, not title-configurable). The\nreceiver calls `getIncomingTradeOffers` to see it, then either\n`acceptTradeOffer` (→ `Status: \"Accepted\"`, both Collectibles swap owners; also\nrejected if `ExpiresAtUtc <= now`, \"Offer has expired\") or `declineTradeOffer`\n(→ `Status: \"Declined\"`). The sender can `cancelTradeOffer` any offer still\n`\"Pending\"` (→ `Status: \"Cancelled\"`).\n\n**`\"Expired\"` is a declared `Status` value the backend never actually\nwrites** — there is no sweep job that flips stale offers to `Expired`.\n`getIncomingTradeOffers` filters server-side to `Status == \"Pending\" &&\nExpiresAtUtc > now`, so an expired incoming offer just silently drops out of\nthat list (it doesn't surface with a distinguishable status). `getMyTradeOffers`\n(outgoing) has **no such filter** — it returns everything you've ever sent for\nthat collection (newest 20), so a lapsed offer you sent still reads\n`Status: \"Pending\"` with an `ExpiresAtUtc` in the past; compare `ExpiresAtUtc`\nagainst the current time yourself if you need to grey it out in a \"my offers\"\nlist. None of the four trade actions mutate `client.data.user` directly (no\n`Resources`, no `Collection` cache patch) — re-fetch `getUserState()` / the\noffer lists to observe the effect.\n\n---\n\n## Responses\n\n```ts\ninterface GrantedCollectible {\n CollectibleID: string;\n Rarity?: number;\n IsSpecial?: boolean;\n IsDuplicate?: boolean;\n CollectionCurrencyConverted?: number; // set when IsDuplicate\n}\n\ninterface OpenPackResponse {\n GrantedCollectibles?: GrantedCollectible[]; // full pull list (incl. duplicates)\n DuplicateCollectibles?: GrantedCollectible[]; // subset that were duplicates\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n NewlyCompletedSetIDs?: string[];\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // pack Cost debit (+ BonusRewardSlots grants)\n TriggeredPity?: unknown[];\n}\n\ninterface OpenCollectionChestResponse {\n GrantedCollectibles?: GrantedCollectible[];\n DuplicateCollectibles?: GrantedCollectible[];\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n Resources?: ResourceOperation; // CollectionCurrencyCost debit (+ bonus grants)\n TriggeredPity?: unknown[];\n}\n\ninterface UseCollectibleJokerResponse {\n GrantedCollectibleID?: string;\n NewlyCompletedSetID?: string;\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // Joker item consumed\n}\n\ninterface ClaimSetRewardResponse {\n SetID: string;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimGrandPrizeResponse {\n Resources?: ResourceOperation;\n}\n\ninterface SendTradeOfferResponse {\n OfferID: string;\n ExpiresAtUtc?: string;\n Resources?: ResourceOperation; // usually absent; trading has no inherent cost\n}\n\ninterface AcceptTradeOfferResponse {\n OfferID: string;\n ReceivedCollectibleID?: string;\n ReceivedIsSpecial?: boolean;\n SentCollectibleID?: string;\n SentCollectibleIsSpecial?: boolean;\n Transfer?: unknown; // server-internal transfer record, not strictly typed\n}\n```\n\n`ClaimSetRewardsBatchResponse` is `BatchItemResult<ClaimSetRewardResponse>[]`\n— see the shared `BatchItemResult<T>` shape\n(`_shared/BatchModels.ts`): `{ Id, Success, Error?, Data? }` per item, one\natomic charge across the whole batch.\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Currency data model — reference\n\nFull shape of the `CurrencyDefinitions` config, and — the canonical\ndocumentation for the whole SDK — the shared `ResourceConsume` /\n`ResourceGrant` / `ResourceOperation` / `ResourceEntry` cost-and-reward\nprimitives. All types are **strictly typed** and exported from\n`@idosgames/core`; every object schema keeps `.passthrough()`, so a field the\nbackend adds later still round-trips instead of being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CurrencyDefinitions](#config-currencydefinitions)\n- [VirtualCurrencyDefinition](#virtualcurrencydefinition)\n- [CryptoCurrencyDefinition](#cryptocurrencydefinition)\n- [Shared conversion config](#shared-conversion-config)\n- [Backend conversion formulas](#backend-conversion-formulas) — transcribed from `ConversionService.cs`\n- [The shared resource primitives](#the-shared-resource-primitives) — canonical home\n - [ResourceEntry](#resourceentry)\n - [ResourceBundle](#resourcebundle)\n - [PremiumTierBundle](#premiumtierbundle)\n - [ResourceGrant](#resourcegrant)\n - [ResourceConsume](#resourceconsume)\n - [ResourceOperation](#resourceoperation)\n - [EventTokenOperation / EventTokenAddress](#eventtokenoperation--eventtokenaddress)\n - [ResourceDualPartyResult / ResourceTransferResult](#resourcedualpartyresult--resourcetransferresult)\n- [How the SDK applies a ResourceOperation](#how-the-sdk-applies-a-resourceoperation)\n\n---\n\n## Config: CurrencyDefinitions\n\n```ts\ninterface CurrencyDefinitions {\n VirtualCurrencies?: Record<string, VirtualCurrencyDefinition> | null;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition> | null;\n}\n```\n\nKey in both maps is the `CurrencyID`. A currency is \"known\" iff it has an\nentry in one of these maps under its `CurrencyType` (`Virtual` or `Crypto`).\n\n---\n\n## VirtualCurrencyDefinition\n\n```ts\ninterface VirtualCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>; // \"icon\", ...\n Economy?: {\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string; // ISO timestamp\n InitialDeposit?: number; // starting balance for new players\n MinBalance?: number;\n MaxBalance?: number;\n DailyEarnLimit?: number;\n DailySpendLimit?: number;\n };\n Recharge?: {\n // energy-style auto-regen\n Rate?: number;\n Max?: number;\n Period?: number;\n };\n Conversion?: CurrencyConversion; // see below\n Permissions?: {\n IsTradable?: boolean;\n IsPurchasable?: boolean;\n IsRefundable?: boolean;\n };\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n```\n\n`Status` governs whether the currency is usable/visible; `\"Maintenance\"` /\n`\"Deprecated\"` currencies typically reject conversions server-side even if\n`Conversion.Enabled` is true.\n\n---\n\n## CryptoCurrencyDefinition\n\n```ts\ninterface CryptoCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n DisplayDecimals?: number; // UI rounding, not wire precision\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string;\n DeveloperDepositSharePercent?: string; // decimal string; see blockchain-system\n Networks?: CryptoNetworkBinding[]; // per-chain bindings\n Limits?: {\n DailyWithdrawUsd?: string;\n MonthlyWithdrawUsd?: string;\n KycRequiredAboveUsd?: string;\n };\n Permissions?: {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n SpendableInGame?: boolean;\n ConvertibleToVirtual?: boolean; // gates cryptoConvert eligibility\n };\n Conversion?: CurrencyConversion;\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n\ninterface CryptoNetworkBinding {\n NetworkID: string;\n ContractAddress?: string;\n Decimals?: number; // on-chain token decimals\n MinDeposit?: string;\n MinWithdraw?: string;\n WithdrawFee?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nDeposit/withdrawal flows (and `Networks`/`Limits` enforcement for those flows)\nbelong to the Blockchain module — see the blockchain-system skill.\n`Permissions.ConvertibleToVirtual` is the flag most relevant here: it's what\nlets a crypto balance participate in `cryptoConvert`.\n\n---\n\n## Shared conversion config\n\nBoth currency kinds reuse the same `CurrencyConversion` shape for their\n`Conversion` field:\n\n```ts\ninterface CurrencyConversion {\n Enabled?: boolean;\n RateMode?: \"Automatic\" | \"Manual\";\n FeePercent?: string; // decimal string\n Targets?: ConversionTarget[]; // whitelist of valid conversion targets\n}\n\ninterface ConversionTarget {\n TargetCurrencyType?: \"Virtual\" | \"Crypto\";\n TargetCurrencyID: string;\n Rate?: string; // decimal string; used when RateMode is \"Manual\"\n MinAmount?: number;\n MaxAmount?: number;\n DailyLimit?: number;\n}\n```\n\nA conversion is only accepted if the source currency's `Conversion.Enabled`\nis true and the target appears in `Targets` (by type + id). `\"Automatic\"`\nrate mode means the backend derives the rate from each side's `ValueInUSD`;\n`\"Manual\"` uses the `Rate` pinned on the `ConversionTarget`. Either way, treat\n`RateApplied` on the response as the source of truth — don't recompute it.\n\n---\n\n## Backend conversion formulas\n\nTranscribed from `ConversionService.ConvertAsync` /\n`ConversionService.ConvertCryptoAsync` in the backend\n(`IDosGamesSDK/API/Client/v2/Currency/Services/ConversionService.cs`). These\nare enforced server-side; the SDK never recomputes them — use this section\nonly for building accurate cost/reward **previews**, not for validating a\nconversion before sending it.\n\n**Order of checks** (any failure short-circuits, no partial debit):\n\n1. Basic shape: non-empty `SourceID`/`TargetID`, positive amount, source ≠\n target.\n2. `Convert` (VC↔VC) rejects a `Crypto` source outright (\"Convert supports\n VC↔VC only\") and a `Crypto` target outright (\"Virtual→Crypto conversion is\n not supported\"). `CryptoConvert` rejects a non-`Crypto` source outright\n (\"CryptoConvert requires source to be Crypto\").\n3. **Status**: source or target `Maintenance` → rejected on that side (\"is\n under maintenance\"). Target (only) `Deprecated` → rejected (\"is deprecated\n and cannot receive new credits\"). A `Deprecated` **source** is allowed —\n deprecating a currency only stops new inflow, it doesn't trap the player's\n remaining balance.\n4. Source's `Conversion` must be non-null and `Enabled`, and must have a\n `Targets` entry matching `(TargetCurrencyType, TargetCurrencyID)` exactly —\n otherwise \"Conversion from 'X' to 'Y' is not allowed.\"\n5. Crypto-source → Virtual-target additionally requires\n `CryptoCurrencyPermissions.ConvertibleToVirtual` — false rejects even a\n listed target.\n6. Per-pair `MinAmount`/`MaxAmount` on the matched `ConversionTarget`, checked\n against the raw source amount before fee.\n7. **Rate resolution**:\n - `RateMode = Manual` → `rate = ConversionTarget.Rate`; a pair configured\n Manual with no `Rate` set is rejected (\"Manual conversion rate is not set\n for this pair\"), not treated as 0 or 1.\n - `RateMode = Automatic` → `rate = source.ValueInUSD / target.ValueInUSD`.\n Either side missing/zero `ValueInUSD` rejects the conversion (\"Automatic\n rate cannot be computed\").\n8. **Fee**: `FeePercent` is clamped to `[0, 100]` defensively, then\n `feeAmount = sourceAmount * FeePercent / 100`; `netSource = sourceAmount -\nfeeAmount`. `netSource <= 0` is rejected.\n9. **Output**: `output = netSource * rate`.\n - VC↔VC (`ConvertAsync`): `output` is cast straight to `long`, i.e.\n **truncated toward zero**. `output <= 0` after truncation is rejected\n (\"Resulting target amount is zero\").\n - Crypto-source (`ConvertCryptoAsync`): `output` stays a full-precision\n `decimal` if the target is `Crypto`. If the target is `Virtual`, it is\n **floored** (`Math.Floor`) to a `long` before the zero-check.\n10. Per-pair `DailyLimit` on the matched `ConversionTarget`: today's\n already-converted amount for this exact `(SourceType:SourceID ->\nTargetType:TargetID)` pair (tracked server-side per UTC day; not exposed\n to the client) plus this operation's raw source amount must not exceed\n it.\n11. The debit/credit itself runs through `ResourceService\n.ApplyResourceOperationAtomicAsync`, which additionally enforces the\n source currency's own `Economy.MinBalance`/`MaxBalance` (for VC) and\n `Economy.DailyEarnLimit`/`DailySpendLimit` — a **second, independent**\n limit layer scoped to the whole currency rather than this one pair. A\n crypto source's live balance is checked directly against\n `InventoryV2.CryptoCurrencies[id].Amount` before the debit.\n\n**Practical takeaway**: two conversions that look identical (same pair, same\namount) can differ in outcome depending on how much of the _daily_ pair\nallowance or the _daily_ currency-wide allowance is already used — always\nrender the server's `error` rather than trying to precompute eligibility.\n\n---\n\n## The shared resource primitives\n\nThis is the **canonical documentation** for these types — every other module\nskill (Store, Character, Craft, Lootbox, Blockchain, …) links here instead of\nredefining them. They describe \"spend this, receive that\" in one uniform\nshape used for offer costs, upgrade costs, craft inputs/outputs, lootbox\nprices/rewards, and blockchain deposit/withdrawal resource deltas.\n\n### ResourceEntry\n\nThe atomic unit: one currency or item quantity.\n\n```ts\ninterface ResourceEntry {\n Type?:\n | \"Item\"\n | \"VirtualCurrency\"\n | \"CryptoCurrency\"\n | \"Purchase\"\n | \"RewardedVideoCredit\"; // ResourceEntryType\n CurrencyID?: string; // set when Type is a currency kind\n Amount?: number; // integer amount; C# `long` on the wire, parsed via zVcAmount (exact up to 2^53-1)\n CatalogID?: string; // set when Type is \"Item\": which catalog\n ItemID?: string; // set when Type is \"Item\": which item definition\n ProductID?: string; // set when Type is \"Purchase\": the IAP product that pays for this entry\n}\n```\n\nOnly the fields relevant to `Type` are populated — e.g. a `VirtualCurrency`\nentry sets `CurrencyID` + `Amount` and leaves `CatalogID`/`ItemID` unset; an\n`Item` entry sets `CatalogID`/`ItemID` (+ `Amount` for stackable quantity) and\nleaves `CurrencyID` unset.\n\n### ResourceBundle\n\nA flat list of entries, plus optional event-token deltas:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[] | null;\n EventTokens?: EventTokenOperation[] | null;\n}\n```\n\n### PremiumTierBundle\n\nAn alternate bundle that only applies if the player holds a qualifying\npremium tier — used inside `ResourceGrant`/`ResourceConsume` to express\n\"VIPs get a better grant / a cheaper cost.\"\n\n```ts\ninterface PremiumTierBundle {\n MinPremiumTier?: number;\n RequiredPremiumID?: string;\n Resources?: ResourceBundle | null;\n}\n```\n\n### ResourceGrant\n\nWhat a player receives.\n\n```ts\ninterface ResourceGrant {\n Standard?: ResourceBundle | null; // baseline grant, always applies\n PremiumBonuses?: unknown[] | null; // reserved/opaque bonus list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated additional/alternate grants\n}\n```\n\n### ResourceConsume\n\nWhat a player is charged. Mirror-shaped to `ResourceGrant`, but the\ntier-based array is a **discount** mechanism rather than a bonus one — see\nGotchas below.\n\n```ts\ninterface ResourceConsume {\n Standard?: ResourceBundle | null; // baseline cost\n PremiumDiscounts?: unknown[] | null; // reserved/opaque discount list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated reduced/alternate cost\n}\n```\n\n### ResourceOperation\n\nThe full manifest for one action: what's granted and what's consumed. This is\nthe shape every \"did an action succeed\" response embeds under a `Resources`\nfield (e.g. `DepositNFTResponse.Resources`, `NFTWithdrawalResponse.Resources`\nin Blockchain).\n\n```ts\ninterface ResourceOperation {\n Grant?: ResourceGrant | null;\n Consume?: ResourceConsume | null;\n}\n```\n\nEither side can be `null`/absent — a pure grant (no cost) sets only `Grant`;\na pure charge (no payout) sets only `Consume`.\n\n### EventTokenOperation / EventTokenAddress\n\nEvent tokens are a lighter-weight counter mechanic (e.g. season/event\ncurrency) addressed by an entity rather than a flat `CurrencyID`:\n\n```ts\ninterface EventTokenAddress {\n Type?: string; // EventTokenType — which kind of entity owns the token bucket\n EntityID: string;\n}\n\ninterface EventTokenOperation {\n Address?: EventTokenAddress | null;\n Amount?: number;\n Source?: string; // free-form provenance tag\n}\n```\n\n### ResourceDualPartyResult / ResourceTransferResult\n\nUsed by PvP/transfer-style features where two accounts are affected by one\naction:\n\n```ts\n// Each side gets its own independent grant/consume manifest.\ninterface ResourceDualPartyResult {\n FromUserID?: string;\n ToUserID?: string;\n FromResult?: ResourceOperation | null;\n ToResult?: ResourceOperation | null;\n}\n\n// A straight transfer: one bundle moves from one account to another.\ninterface ResourceTransferResult {\n FromUserID?: string;\n ToUserID?: string;\n Transferred?: ResourceBundle | null;\n}\n```\n\n---\n\n## How the SDK applies a ResourceOperation\n\nEvery module that returns a `ResourceOperation` (directly, or via a\n`Resources` field) has already had it **applied server-side**; the SDK's job\nis only to mirror it into the local cache so balances/inventory read\ncorrectly without a re-fetch. Internally this goes through\n`UserData.applyResourceOperation(op, itemDefs)`, which:\n\n- walks `Consume.Standard.Entries` and `Grant.Standard.Entries` (the\n `PremiumDiscounts`/`PremiumTiers`/`PremiumBonuses` arrays describe _why_ the\n standard amount is what it is — the server has already resolved them into\n `Standard` before sending the response; the client does not re-apply tiers),\n- for `VirtualCurrency` entries, adjusts the integer balance and emits\n `user:virtualCurrencyUpdated`,\n- for `Item` entries, adjusts stackable counts / creates unstackable instances\n and emits `user:inventoryUpdated`,\n- for `EventTokens`, adjusts the addressed token bucket and emits\n `user:eventTokenUpdated`,\n- always emits the umbrella `user:anyUpdated` when anything changed.\n\n`CryptoCurrency` amounts do **not** flow through this integer pipeline —\nthey're decimal and go through a separate patch\n(`UserData.patchCryptoCurrencyDelta(currencyID, delta, serverTimeUtc)`), which\nis what `CurrencyService.cryptoConvert` and the Blockchain deposit/withdrawal\nmethods use directly instead of embedding crypto deltas in a\n`ResourceOperation`.\n\n**Practical takeaway when building UI in any module:** don't hand-roll cost\npreviews from `PremiumDiscounts`/`PremiumTiers` internals unless you're\nexplicitly building a \"your VIP tier saves you N%\" comparison — for \"what will\nthis cost me right now,\" prefer the value the server already resolved\n(`Standard`, or the flat response fields like `ConvertResponse.SourceSpent`).\nTreat `Amount` on VC entries as a signed integer conceptually (consume vs.\ngrant is which container it's in, not a negative number) and crypto strings as\nopaque decimal values to hand to `decimal.js`, not to parse with `Number()`\nonce you're near precision limits.\n"
8
+ "content": "# Currency data model — reference\n\nFull shape of the `CurrencyDefinitions` config, and — the canonical\ndocumentation for the whole SDK — the shared `ResourceConsume` /\n`ResourceGrant` / `ResourceOperation` / `ResourceEntry` cost-and-reward\nprimitives. All types are **strictly typed** and exported from\n`@idosgames/core`; every object schema keeps `.passthrough()`, so a field the\nbackend adds later still round-trips instead of being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CurrencyDefinitions](#config-currencydefinitions)\n- [VirtualCurrencyDefinition](#virtualcurrencydefinition)\n- [CryptoCurrencyDefinition](#cryptocurrencydefinition)\n- [Shared conversion config](#shared-conversion-config)\n- [Backend conversion formulas](#backend-conversion-formulas) — transcribed from `ConversionService.cs`\n- [The shared resource primitives](#the-shared-resource-primitives) — canonical home\n - [ResourceEntry](#resourceentry)\n - [ResourceBundle](#resourcebundle)\n - [PremiumTierBundle](#premiumtierbundle)\n - [ResourceGrant](#resourcegrant)\n - [ResourceConsume](#resourceconsume)\n - [ResourceOperation](#resourceoperation)\n - [EventTokenOperation / EventTokenAddress](#eventtokenoperation--eventtokenaddress)\n - [ResourceDualPartyResult / ResourceTransferResult](#resourcedualpartyresult--resourcetransferresult)\n- [How the SDK applies a ResourceOperation](#how-the-sdk-applies-a-resourceoperation)\n\n---\n\n## Config: CurrencyDefinitions\n\n```ts\ninterface CurrencyDefinitions {\n VirtualCurrencies?: Record<string, VirtualCurrencyDefinition> | null;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition> | null;\n}\n```\n\nKey in both maps is the `CurrencyID`. A currency is \"known\" iff it has an\nentry in one of these maps under its `CurrencyType` (`Virtual` or `Crypto`).\n\n---\n\n## VirtualCurrencyDefinition\n\n```ts\ninterface VirtualCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>; // \"icon\", ...\n Economy?: {\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string; // ISO timestamp\n InitialDeposit?: number; // starting balance for new players\n MinBalance?: number;\n MaxBalance?: number;\n DailyEarnLimit?: number;\n DailySpendLimit?: number;\n };\n Recharge?: {\n // Energy-style auto-regen, credited in BATCHES: every FULL `Period` seconds the\n // player gets `Rate` units at once, up to `Max`. Rate=5/Period=60 means \"+5 once a\n // minute\", NOT \"+1 every 12 seconds\" — an incomplete period credits nothing.\n // The batch is clipped exactly at `Max` (if less than Rate is missing, only the\n // remainder is credited); at or above `Max` nothing is credited.\n // `Max` is the auto-recharge cap only — explicit grants may exceed it, up to\n // Economy.MaxBalance.\n Rate?: number;\n Max?: number;\n Period?: number;\n };\n Conversion?: CurrencyConversion; // see below\n Permissions?: {\n IsTradable?: boolean;\n IsPurchasable?: boolean;\n IsRefundable?: boolean;\n };\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n```\n\n`Status` governs whether the currency is usable/visible; `\"Maintenance\"` /\n`\"Deprecated\"` currencies typically reject conversions server-side even if\n`Conversion.Enabled` is true.\n\n---\n\n## CryptoCurrencyDefinition\n\n```ts\ninterface CryptoCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n DisplayDecimals?: number; // UI rounding, not wire precision\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string;\n DeveloperDepositSharePercent?: string; // decimal string; see blockchain-system\n Networks?: CryptoNetworkBinding[]; // per-chain bindings\n Limits?: {\n DailyWithdrawUsd?: string;\n MonthlyWithdrawUsd?: string;\n KycRequiredAboveUsd?: string;\n };\n Permissions?: {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n SpendableInGame?: boolean;\n ConvertibleToVirtual?: boolean; // gates cryptoConvert eligibility\n };\n Conversion?: CurrencyConversion;\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n\ninterface CryptoNetworkBinding {\n NetworkID: string;\n ContractAddress?: string;\n Decimals?: number; // on-chain token decimals\n MinDeposit?: string;\n MinWithdraw?: string;\n WithdrawFee?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nDeposit/withdrawal flows (and `Networks`/`Limits` enforcement for those flows)\nbelong to the Blockchain module — see the blockchain-system skill.\n`Permissions.ConvertibleToVirtual` is the flag most relevant here: it's what\nlets a crypto balance participate in `cryptoConvert`.\n\n---\n\n## Shared conversion config\n\nBoth currency kinds reuse the same `CurrencyConversion` shape for their\n`Conversion` field:\n\n```ts\ninterface CurrencyConversion {\n Enabled?: boolean;\n RateMode?: \"Automatic\" | \"Manual\";\n FeePercent?: string; // decimal string\n Targets?: ConversionTarget[]; // whitelist of valid conversion targets\n}\n\ninterface ConversionTarget {\n TargetCurrencyType?: \"Virtual\" | \"Crypto\";\n TargetCurrencyID: string;\n Rate?: string; // decimal string; used when RateMode is \"Manual\"\n MinAmount?: number;\n MaxAmount?: number;\n DailyLimit?: number;\n}\n```\n\nA conversion is only accepted if the source currency's `Conversion.Enabled`\nis true and the target appears in `Targets` (by type + id). `\"Automatic\"`\nrate mode means the backend derives the rate from each side's `ValueInUSD`;\n`\"Manual\"` uses the `Rate` pinned on the `ConversionTarget`. Either way, treat\n`RateApplied` on the response as the source of truth — don't recompute it.\n\n---\n\n## Backend conversion formulas\n\nTranscribed from `ConversionService.ConvertAsync` /\n`ConversionService.ConvertCryptoAsync` in the backend\n(`IDosGamesSDK/API/Client/v2/Currency/Services/ConversionService.cs`). These\nare enforced server-side; the SDK never recomputes them — use this section\nonly for building accurate cost/reward **previews**, not for validating a\nconversion before sending it.\n\n**Order of checks** (any failure short-circuits, no partial debit):\n\n1. Basic shape: non-empty `SourceID`/`TargetID`, positive amount, source ≠\n target.\n2. `Convert` (VC↔VC) rejects a `Crypto` source outright (\"Convert supports\n VC↔VC only\") and a `Crypto` target outright (\"Virtual→Crypto conversion is\n not supported\"). `CryptoConvert` rejects a non-`Crypto` source outright\n (\"CryptoConvert requires source to be Crypto\").\n3. **Status**: source or target `Maintenance` → rejected on that side (\"is\n under maintenance\"). Target (only) `Deprecated` → rejected (\"is deprecated\n and cannot receive new credits\"). A `Deprecated` **source** is allowed —\n deprecating a currency only stops new inflow, it doesn't trap the player's\n remaining balance.\n4. Source's `Conversion` must be non-null and `Enabled`, and must have a\n `Targets` entry matching `(TargetCurrencyType, TargetCurrencyID)` exactly —\n otherwise \"Conversion from 'X' to 'Y' is not allowed.\"\n5. Crypto-source → Virtual-target additionally requires\n `CryptoCurrencyPermissions.ConvertibleToVirtual` — false rejects even a\n listed target.\n6. Per-pair `MinAmount`/`MaxAmount` on the matched `ConversionTarget`, checked\n against the raw source amount before fee.\n7. **Rate resolution**:\n - `RateMode = Manual` → `rate = ConversionTarget.Rate`; a pair configured\n Manual with no `Rate` set is rejected (\"Manual conversion rate is not set\n for this pair\"), not treated as 0 or 1.\n - `RateMode = Automatic` → `rate = source.ValueInUSD / target.ValueInUSD`.\n Either side missing/zero `ValueInUSD` rejects the conversion (\"Automatic\n rate cannot be computed\").\n8. **Fee**: `FeePercent` is clamped to `[0, 100]` defensively, then\n `feeAmount = sourceAmount * FeePercent / 100`; `netSource = sourceAmount -\nfeeAmount`. `netSource <= 0` is rejected.\n9. **Output**: `output = netSource * rate`.\n - VC↔VC (`ConvertAsync`): `output` is cast straight to `long`, i.e.\n **truncated toward zero**. `output <= 0` after truncation is rejected\n (\"Resulting target amount is zero\").\n - Crypto-source (`ConvertCryptoAsync`): `output` stays a full-precision\n `decimal` if the target is `Crypto`. If the target is `Virtual`, it is\n **floored** (`Math.Floor`) to a `long` before the zero-check.\n10. Per-pair `DailyLimit` on the matched `ConversionTarget`: today's\n already-converted amount for this exact `(SourceType:SourceID ->\nTargetType:TargetID)` pair (tracked server-side per UTC day; not exposed\n to the client) plus this operation's raw source amount must not exceed\n it.\n11. The debit/credit itself runs through `ResourceService\n.ApplyResourceOperationAtomicAsync`, which additionally enforces the\n source currency's own `Economy.MinBalance`/`MaxBalance` (for VC) and\n `Economy.DailyEarnLimit`/`DailySpendLimit` — a **second, independent**\n limit layer scoped to the whole currency rather than this one pair. A\n crypto source's live balance is checked directly against\n `InventoryV2.CryptoCurrencies[id].Amount` before the debit.\n\n**Practical takeaway**: two conversions that look identical (same pair, same\namount) can differ in outcome depending on how much of the _daily_ pair\nallowance or the _daily_ currency-wide allowance is already used — always\nrender the server's `error` rather than trying to precompute eligibility.\n\n---\n\n## The shared resource primitives\n\nThis is the **canonical documentation** for these types — every other module\nskill (Store, Character, Craft, Lootbox, Blockchain, …) links here instead of\nredefining them. They describe \"spend this, receive that\" in one uniform\nshape used for offer costs, upgrade costs, craft inputs/outputs, lootbox\nprices/rewards, and blockchain deposit/withdrawal resource deltas.\n\n### ResourceEntry\n\nThe atomic unit: one currency or item quantity.\n\n```ts\ninterface ResourceEntry {\n Type?:\n | \"Item\"\n | \"VirtualCurrency\"\n | \"CryptoCurrency\"\n | \"Purchase\"\n | \"RewardedVideoCredit\"; // ResourceEntryType\n CurrencyID?: string; // set when Type is a currency kind\n Amount?: number; // integer amount; C# `long` on the wire, parsed via zVcAmount (exact up to 2^53-1)\n CatalogID?: string; // set when Type is \"Item\": which catalog\n ItemID?: string; // set when Type is \"Item\": which item definition\n ProductID?: string; // set when Type is \"Purchase\": the IAP product that pays for this entry\n}\n```\n\nOnly the fields relevant to `Type` are populated — e.g. a `VirtualCurrency`\nentry sets `CurrencyID` + `Amount` and leaves `CatalogID`/`ItemID` unset; an\n`Item` entry sets `CatalogID`/`ItemID` (+ `Amount` for stackable quantity) and\nleaves `CurrencyID` unset.\n\n### ResourceBundle\n\nA flat list of entries, plus optional event-token deltas:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[] | null;\n EventTokens?: EventTokenOperation[] | null;\n}\n```\n\n### PremiumTierBundle\n\nAn alternate bundle that only applies if the player holds a qualifying\npremium tier — used inside `ResourceGrant`/`ResourceConsume` to express\n\"VIPs get a better grant / a cheaper cost.\"\n\n```ts\ninterface PremiumTierBundle {\n MinPremiumTier?: number;\n RequiredPremiumID?: string;\n Resources?: ResourceBundle | null;\n}\n```\n\n### ResourceGrant\n\nWhat a player receives.\n\n```ts\ninterface ResourceGrant {\n Standard?: ResourceBundle | null; // baseline grant, always applies\n PremiumBonuses?: unknown[] | null; // reserved/opaque bonus list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated additional/alternate grants\n}\n```\n\n### ResourceConsume\n\nWhat a player is charged. Mirror-shaped to `ResourceGrant`, but the\ntier-based array is a **discount** mechanism rather than a bonus one — see\nGotchas below.\n\n```ts\ninterface ResourceConsume {\n Standard?: ResourceBundle | null; // baseline cost\n PremiumDiscounts?: unknown[] | null; // reserved/opaque discount list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated reduced/alternate cost\n}\n```\n\n### ResourceOperation\n\nThe full manifest for one action: what's granted and what's consumed. This is\nthe shape every \"did an action succeed\" response embeds under a `Resources`\nfield (e.g. `DepositNFTResponse.Resources`, `NFTWithdrawalResponse.Resources`\nin Blockchain).\n\n```ts\ninterface ResourceOperation {\n Grant?: ResourceGrant | null;\n Consume?: ResourceConsume | null;\n}\n```\n\nEither side can be `null`/absent — a pure grant (no cost) sets only `Grant`;\na pure charge (no payout) sets only `Consume`.\n\n### EventTokenOperation / EventTokenAddress\n\nEvent tokens are a lighter-weight counter mechanic (e.g. season/event\ncurrency) addressed by an entity rather than a flat `CurrencyID`:\n\n```ts\ninterface EventTokenAddress {\n Type?: string; // EventTokenType — which kind of entity owns the token bucket\n EntityID: string;\n}\n\ninterface EventTokenOperation {\n Address?: EventTokenAddress | null;\n Amount?: number;\n Source?: string; // free-form provenance tag\n}\n```\n\n### ResourceDualPartyResult / ResourceTransferResult\n\nUsed by PvP/transfer-style features where two accounts are affected by one\naction:\n\n```ts\n// Each side gets its own independent grant/consume manifest.\ninterface ResourceDualPartyResult {\n FromUserID?: string;\n ToUserID?: string;\n FromResult?: ResourceOperation | null;\n ToResult?: ResourceOperation | null;\n}\n\n// A straight transfer: one bundle moves from one account to another.\ninterface ResourceTransferResult {\n FromUserID?: string;\n ToUserID?: string;\n Transferred?: ResourceBundle | null;\n}\n```\n\n---\n\n## How the SDK applies a ResourceOperation\n\nEvery module that returns a `ResourceOperation` (directly, or via a\n`Resources` field) has already had it **applied server-side**; the SDK's job\nis only to mirror it into the local cache so balances/inventory read\ncorrectly without a re-fetch. Internally this goes through\n`UserData.applyResourceOperation(op, itemDefs)`, which:\n\n- walks `Consume.Standard.Entries` and `Grant.Standard.Entries` (the\n `PremiumDiscounts`/`PremiumTiers`/`PremiumBonuses` arrays describe _why_ the\n standard amount is what it is — the server has already resolved them into\n `Standard` before sending the response; the client does not re-apply tiers),\n- for `VirtualCurrency` entries, adjusts the integer balance and emits\n `user:virtualCurrencyUpdated`,\n- for `Item` entries, adjusts stackable counts / creates unstackable instances\n and emits `user:inventoryUpdated`,\n- for `EventTokens`, adjusts the addressed token bucket and emits\n `user:eventTokenUpdated`,\n- always emits the umbrella `user:anyUpdated` when anything changed.\n\n`CryptoCurrency` amounts do **not** flow through this integer pipeline —\nthey're decimal and go through a separate patch\n(`UserData.patchCryptoCurrencyDelta(currencyID, delta, serverTimeUtc)`), which\nis what `CurrencyService.cryptoConvert` and the Blockchain deposit/withdrawal\nmethods use directly instead of embedding crypto deltas in a\n`ResourceOperation`.\n\n**Practical takeaway when building UI in any module:** don't hand-roll cost\npreviews from `PremiumDiscounts`/`PremiumTiers` internals unless you're\nexplicitly building a \"your VIP tier saves you N%\" comparison — for \"what will\nthis cost me right now,\" prefer the value the server already resolved\n(`Standard`, or the flat response fields like `ConvertResponse.SourceSpent`).\nTreat `Amount` on VC entries as a signed integer conceptually (consume vs.\ngrant is which container it's in, not a negative number) and crypto strings as\nopaque decimal values to hand to `decimal.js`, not to parse with `Number()`\nonce you're near precision limits.\n"
9
9
  }
10
10
  ]
11
11
  }