@idosgames/mcp 0.1.10 → 0.1.12
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.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +19 -3
- package/registry/index.json +119 -23
- package/registry/modules/board-game.json +31 -10
- package/registry/modules/game-hud.json +99 -0
- package/registry/modules/idle-rpg.json +35 -10
- package/registry/modules/voxelcraft.json +7 -2
- package/registry/skills/acquisition-attribution.json +1 -1
- package/registry/skills/blockchain-system.json +2 -2
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/chat-system.json +6 -0
- package/registry/skills/community-marketing-system.json +6 -0
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/currency-system.json +2 -2
- package/registry/skills/deal-offer-system.json +2 -2
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +6 -0
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/push-notifications.json +6 -0
- package/registry/skills/store-system.json +3 -3
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "craft-system",
|
|
3
3
|
"description": "Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.craft (CraftService): load craft recipe definitions and execute a craft that burns input item instances (trade-up by rarity or trade-up by collection) to produce a rolled output item. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage UIs, or otherwise touches client.craft, CraftService, CraftDefinitions, CraftDefinition, or CraftResponse — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?)` | Burn `inputItemIDs`, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are item _instance_ ids, not catalog/definition ids.** The\n server checks each instance's underlying `ItemID` against the recipe's\n allowed-input set (by `InputRarityID`, and by `CollectionID` too for\n `TradeUpCollection`) and that the player actually holds enough of that item\n in total (equipped instances don't count — see Gotchas).\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<OptionID, { OptionID, Name, Cost, AllowedPlatforms }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the instance ids actually consumed for this iteration\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.Cost.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Craft\" burns items twice. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
|
|
4
|
+
"content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?, inputInstanceIDs?)` | Burn the inputs, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are catalog `ItemID`s, not instance ids.** The server checks\n each id against the recipe's allowed-input set (by `InputRarityID`, and by\n `CollectionID` too for `TradeUpCollection`) and that the player holds enough\n of that item in total (equipped instances don't count — see Gotchas). WHICH\n copies burn is decided by the recipe's `InputSelection` — see \"Output level\n from input levels\" below; to name specific instances use `inputInstanceIDs`.\n- **`inputInstanceIDs`** — only for a recipe with `InputSelection:\n\"ClientSelected\"`. One instance id per **unstackable** input of the\n _expanded_ list (template × `count`), in the same order; stackable inputs\n take no slot. A bundle's id may repeat once per unit it holds. Sending them to\n any other recipe is rejected (`\"InputInstanceIDs are accepted only by a craft\nwith InputSelection = ClientSelected.\"`); omitting them on a ClientSelected\n recipe falls back to the server picking non-upgraded copies only.\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<OptionID, { OptionID, Name, Cost, AllowedPlatforms }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the catalog ItemIDs consumed in this iteration (your template)\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Output level from input levels\n\nA recipe can make the output inherit the level of its inputs. Three config\nfields, all defaulting to the legacy behaviour:\n\n| Field | Values | Absent = |\n| --------------------- | ------------------------------------------------------------------------------ | --------------------------------- |\n| `OutputLevelMode` | `None` / `Min` / `Average` (rounded down) / `Max` — per craft | `None` (output is level 1) |\n| `InputSelection` | `ProtectLeveled` / `ClientSelected` / `LowestLevelFirst` / `HighestLevelFirst` | `ProtectLeveled` (only Level ≤ 1) |\n| `OutputLevelOverflow` | `Clamp` / `Reject` — when the level exceeds the output's `Upgrade.MaxLevel` | `Clamp` |\n\nStackable inputs and non-upgraded copies count as level 1; an output that is\nstackable or has no `Upgrade` is capped at level 1. With `ProtectLeveled`,\n`OutputLevelMode` has no effect — upgraded copies never enter the craft.\nEquipped and expired instances are never burned in any mode.\n\n```ts\n// Recipe: { OutputLevelMode: \"Average\", InputSelection: \"ClientSelected\", RequiredItemCount: 2 }\nconst res = await client.craft.craft(\n \"merge-swords\",\n [\"sword\", \"sword\"], // catalog ItemIDs — the template\n 1,\n undefined,\n [\"inst-lv4\", \"inst-lv7\"], // which copies to burn\n);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.OutputLevel; // 5 — (4 + 7) / 2, rounded down\nres.data.Results?.[0]?.BurnedInstances; // [{ ItemInstanceID, ItemID, Level, Units }, …]\n```\n\n`Reject` is checked against the **lowest** `MaxLevel` of all possible outputs,\nbefore the roll, so the same request never passes on one try and fails on\nanother. A retry with the same idempotency key after the inputs were already\nburned returns the original result (`Results` empty) instead of \"not found\".\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.Cost.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Craft\" burns items twice. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n}\n```\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## PriceOption\n\nOne payment option for a recipe — the platform-wide price shape, identical in\nevery module (see the `checkout-system` skill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string;\n Cost?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A craft can never be paid in a store.** The price is per craft and multiplied\nby `count`, while a receipt pays for exactly one SKU — there is no \"one and a half\nreceipts\" for a batch of one and a half crafts. A `Purchase` entry here is rejected\nwith `\"Craft cannot be paid in a store.\"`\n\n`Cost` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option with\n an empty cost is used, no input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first option\n available on the caller's platform**, ordered by `OptionID`. The order is\n explicit (not dictionary order) so the default is deterministic — but it is\n still \"first\", not \"cheapest\".\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `Purchase` are rejected outright → `\"Craft cannot be\npaid in a store.\"` (see the PriceOption section above)\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the instance ids consumed in this specific iteration\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n}\n```\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
|
|
8
|
+
"content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n\n // Output level from input levels — all absent = legacy behaviour.\n OutputLevelMode?: \"None\" | \"Min\" | \"Average\" | \"Max\"; // per craft; Average rounds down; absent = None\n InputSelection?:\n | \"ProtectLeveled\"\n | \"ClientSelected\"\n | \"LowestLevelFirst\"\n | \"HighestLevelFirst\"; // absent = ProtectLeveled\n OutputLevelOverflow?: \"Clamp\" | \"Reject\"; // vs the output's Upgrade.MaxLevel; absent = Clamp\n}\n```\n\n- `ProtectLeveled` burns only non-upgraded copies (oldest first) — the only\n behaviour that existed before; with it `OutputLevelMode` has no effect.\n- `ClientSelected` burns the instances named in `InputInstanceIDs` (any\n level); without them it behaves like `ProtectLeveled`.\n- `LowestLevelFirst` / `HighestLevelFirst` — the server picks by level\n (then oldest first). Equipped and expired instances are never picked.\n- The output cap is `Upgrade.MaxLevel`; a stackable output or one without\n `Upgrade` is capped at 1. `Reject` compares against the lowest cap in the\n output pool, before the roll.\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## PriceOption\n\nOne payment option for a recipe — the platform-wide price shape, identical in\nevery module (see the `checkout-system` skill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string;\n Cost?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A craft can never be paid in a store.** The price is per craft and multiplied\nby `count`, while a receipt pays for exactly one SKU — there is no \"one and a half\nreceipts\" for a batch of one and a half crafts. A `Purchase` entry here is rejected\nwith `\"Craft cannot be paid in a store.\"`\n\n`Cost` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option with\n an empty cost is used, no input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first option\n available on the caller's platform**, ordered by `OptionID`. The order is\n explicit (not dictionary order) so the default is deterministic — but it is\n still \"first\", not \"cheapest\".\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `Purchase` are rejected outright → `\"Craft cannot be\npaid in a store.\"` (see the PriceOption section above)\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the catalog ItemIDs consumed in this iteration (the template)\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n OutputLevel?: number; // only when OutputLevelMode !== \"None\"\n BurnedInstances?: { ItemInstanceID; ItemID; Level; Units }[]; // only when inputs were pinned to instances\n}\n```\n\nAn output above level 1 is granted as its own instance (a bundle is always\nlevel 1), and pinned inputs are burned by instance id — both are still listed in\n`Resources` (`Consume` / `Grant`) and in the `Inventory` delta, so\n`applyResourcesWithDelta` keeps the cache exact. On an idempotent replay\n`Resources` is the stored operation, which does not carry those two kinds of\nlines.\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "currency-system",
|
|
3
3
|
"description": "Convert between currencies in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.currency (CurrencyService): virtual-currency to virtual-currency (VC↔VC) conversion, and crypto-source conversion (crypto→VC or crypto→crypto). This is also the canonical home for the SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry cost-and-reward types used by every other module (Store, Character, Craft, Lootbox, Blockchain, …). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a currency-exchange screen, gold-to-gems conversion, crypto conversion, or otherwise touches client.currency, CurrencyService, ConvertResponse, CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or ResourceEntry — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: currency-system\ndescription: >-\n Convert between currencies in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.currency (CurrencyService): virtual-currency to\n virtual-currency (VC↔VC) conversion, and crypto-source conversion\n (crypto→VC or crypto→crypto). This is also the canonical home for the\n SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry\n cost-and-reward types used by every other module (Store, Character, Craft,\n Lootbox, Blockchain, …). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n currency-exchange screen, gold-to-gems conversion, crypto conversion, or\n otherwise touches client.currency, CurrencyService, ConvertResponse,\n CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or\n ResourceEntry — even if they don't name the module explicitly.\n---\n\n# Currency system (iDosGames TS SDK)\n\nThe Currency module is small — two methods — but it's the module every other\nsystem rides on: it's the reference implementation for converting one balance\ninto another, and it's the canonical home for the **shared cost/reward\nprimitives** (`ResourceConsume`, `ResourceGrant`, `ResourceOperation`,\n`ResourceEntry`) that Store, Character, Craft, Lootbox, Blockchain, and others\nall use to describe \"this action costs X and grants Y.\" Read this skill once\nand the resource shapes in every other module's docs make sense by reference.\n\nEverything is **server-authoritative**: the client asks the backend to\nconvert, the backend validates status/rate/fee/limits and debits/credits, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate balances yourself, and you never precompute the rate, fee, or\nrounding client-side — the backend owns all of it.\n\nThis skill is for **using** the production `CurrencyService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(disabled conversion, unlisted target, currency under maintenance, daily\nlimit, insufficient funds) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Two currency kinds\n\n- **Virtual currency (VC)** — title-defined, integer balances (gold, gems,\n energy). Config: `VirtualCurrencyDefinition`.\n- **Crypto currency** — on-chain-backed, decimal balances (ETH, USDT, …).\n Config: `CryptoCurrencyDefinition`. Crypto balances/deposits/withdrawals are\n otherwise the Blockchain module's territory — see the blockchain-system\n skill; Currency only covers converting a crypto balance you already hold.\n\nBoth currency kinds share a `CurrencyType` tag (`\"Virtual\"` | `\"Crypto\"`) used\nthroughout requests/responses to disambiguate `CurrencyID`s that could\notherwise collide.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst currency = client.currency; // the CurrencyService\n```\n\nEvery currency method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the 600ms throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Conversion from\n'X' is disabled\", \"is under maintenance\", \"is deprecated and cannot receive\nnew credits\", \"Amount below pair minimum\", \"Per-pair daily limit exceeded\",\ninsufficient balance).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------- |\n| `convert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | VC↔VC conversion only. Integer amounts. | `ConvertResponse` |\n| `cryptoConvert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | Crypto-**source** conversion (crypto→VC or crypto→crypto). Decimal amounts. | `CryptoConvertResponse` |\n\n`sourceAmount` for `convert` is a `number` (truncated to an integer via\n`Math.trunc` before sending — VC balances are integer). For `cryptoConvert` it\nis `Decimal | string | number` (a `decimal.js` `Decimal`, a numeric string, or\na number) — pass a string or `Decimal` for anything beyond safe-integer/float\nprecision; the SDK depends on `decimal.js`. Each method validates locally\nbefore any network call and rejects mismatched pairs:\n\n- `convert` rejects if either side is `CurrencyType.Crypto` — \"Convert\n supports VC↔VC only. Use cryptoConvert for crypto.\"\n- `cryptoConvert` rejects if **both** sides are `CurrencyType.Virtual` —\n \"cryptoConvert requires at least one crypto side. Use convert for VC↔VC.\"\n- Both reject source === target, empty IDs, non-positive amounts.\n- `cryptoConvert` additionally rejects a non-integer amount when the source\n side is `Virtual` (VC amounts must be whole) — \"Virtual currency source\n amount must be integer.\"\n\nOne server-side constraint the SDK preflight does **not** catch: the backend's\n`CryptoConvert` endpoint requires the **source** to be `Crypto`, and\n`Virtual→Crypto` is not supported by any endpoint (`Convert` explicitly fails\nVirtual→Crypto with \"Virtual→Crypto conversion is not supported.\"). So the\nonly valid pairings are `convert` for VC→VC and `cryptoConvert` for crypto→VC\n/ crypto→crypto; a Virtual-source `cryptoConvert` passes the local check but\ncomes back `reason: \"server\"` (\"CryptoConvert requires source to be Crypto.\nUse Convert for VC↔VC.\").\n\n`transactionID` is optional; omit it and the SDK generates a unique one per\ncall (`convert_<sourceType>_<sourceID>_to_<targetType>_<targetID>_<uuid>` /\n`crypto_convert_...`). The backend folds `TransactionID` into its idempotency\nkey (`CurrencyConvert:<key>` / `CurrencyCryptoConvert:<key>`, where `<key>` is\n`TransactionID` verbatim when you supply one), stored per `(userID, reason)`\nfor 7 days. **Retrying with the same `transactionID` is safe** — the server\ndetects the replay and returns the stored result instead of charging again.\nTwo calls with different (e.g. auto-generated) IDs are two real conversions.\n\nOn success, both methods **mirror the confirmed debit/credit into the cache\nand emit an event** — you don't apply anything by hand. Read updated balances\nstraight from the cache.\n\n### Non-obvious server behavior worth knowing before you build UI\n\n- **Rate resolution**: `Automatic` mode divides the source's `ValueInUSD` by\n the target's `ValueInUSD` (`rate = src.ValueInUSD / tgt.ValueInUSD`);\n `Manual` mode uses the fixed `Rate` pinned on that specific\n `ConversionTarget`. Which mode applies is a property of the **source**\n currency (`Conversion.RateMode`), not the pair.\n- **Fee comes off first, then the rate, then rounding**: `fee = sourceAmount *\nFeePercent / 100`; `net = sourceAmount - fee`; `output = net * rate`. Order\n matters for previews.\n- **Rounding is always truncation toward zero, never nearest/ceiling.** VC↔VC\n truncates the final `output` to a `long`. Crypto-source conversions keep\n full decimal precision throughout _except_ when the target is Virtual, where\n the decimal `output` is floored (`Math.Floor`) to a `long`. A conversion\n whose result rounds to 0 (dust) is rejected rather than silently granting\n nothing.\n- **Currency status gates asymmetrically**: `Maintenance` blocks the currency\n on either side; `Deprecated` blocks it only as a **target** (can't receive\n new credits) — a deprecated currency can still be spent down as a _source_.\n- **Two independent limit layers can reject the same call**: the per-pair\n `ConversionTarget.MinAmount` / `MaxAmount` / `DailyLimit` (scoped to this\n exact source→target pair), and the source currency's own\n `Economy.MinBalance` / `MaxBalance` / `DailyEarnLimit` / `DailySpendLimit`\n (scoped to the whole currency, across every way it can change). Either can\n fail independently — don't assume passing one means the other passed.\n\n## Reading state and reacting to changes\n\n```ts\n// Virtual currency balance (integer):\nclient.data.user.getVirtualCurrencyAmount(\"coins\"); // number\n\n// Crypto currency balance (decimal-as-string):\nclient.data.user.getCryptoCurrencyAmount(\"eth\"); // string, e.g. \"0.05\"\n\n// Currency catalog (config). Populated by client.title.getCurrencyDefinitions()\n// (emits \"title:currencyDefinitionsReceived\"), with a fallback to the `Currency`\n// section of the full title public configuration if that's been loaded:\nconst defs = client.data.config.currencyDefinitions; // CurrencyDefinitions | undefined\ndefs?.VirtualCurrencies?.[\"coins\"]?.Conversion?.Targets;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `currency:converted` → `ConvertResponse`\n- `currency:cryptoConverted` → `CryptoConvertResponse`\n\nNeither `convert` nor `cryptoConvert` has its own coarse `user:currencyUpdated`\nevent — the balance change instead rides through the same resource pipeline\nevery other module uses. `convert` (VC↔VC) always applies through the integer\nresource pipeline, so it fires `user:virtualCurrencyUpdated` +\n`user:inventoryUpdated` (plus the umbrella `user:anyUpdated`) for both sides.\n`cryptoConvert` splits by side: a `Virtual` leg goes through the same\nresource pipeline (same events as above for that leg only); a `Crypto` leg\ngoes through a separate decimal patch that fires only\n`user:inventoryUpdated` + `user:anyUpdated` (no\n`user:virtualCurrencyUpdated`, since no VC balance changed). Listen at\nwhichever granularity suits your UI: the specific `currency:*` event for a\ntoast/confirmation, the coarse `user:*` event for a \"re-render balances\" hook.\n\n```ts\nconst off = client.on(\"currency:converted\", (r) => {\n console.log(\n `Spent ${r.SourceSpent} ${r.SourceID} -> got ${r.TargetCredited} ${r.TargetID}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Convert gold to gems (VC↔VC)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // 100 (integer, includes the fee)\nres.data.FeeAmount; // e.g. 10 (integer, source-currency units — 10% fee here)\nres.data.TargetCredited; // e.g. 45 (integer: (100 - 10) * 0.5, truncated)\nres.data.RateApplied; // decimal-as-string, e.g. \"0.5\"\n// Balances are already updated in the cache:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Convert a crypto balance to virtual currency\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\nimport Decimal from \"decimal.js\";\n\nconst res = await client.currency.cryptoConvert(\n CurrencyType.Crypto,\n \"usdt\",\n CurrencyType.Virtual,\n \"gems\",\n new Decimal(\"2.50\"), // decimal source amount\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // \"2.50\" (decimal string)\nres.data.TargetCredited; // \"500\" (decimal string; VC side is still integer-valued and floored)\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Read a cost/reward breakdown from another module's response\n\nCurrency doesn't return `ResourceOperation` itself (its responses are flat\n`ConvertResponse`/`CryptoConvertResponse`), but almost every other module's\nresponse embeds one under a `Resources` field — e.g. a Store purchase, a\nCharacter upgrade, a Blockchain deposit. Once you've read this skill you can\nread any of them the same way:\n\n```ts\nimport type { ResourceOperation } from \"@idosgames/core\";\n\nfunction summarize(op: ResourceOperation | null | undefined) {\n const spent = op?.Consume?.Standard?.Entries ?? [];\n const gained = op?.Grant?.Standard?.Entries ?? [];\n for (const e of spent)\n console.log(`-${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n for (const e of gained)\n console.log(`+${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n}\n```\n\n`Standard` is already the server-resolved final amount (premium\ndiscounts/bonuses folded in) — don't re-derive it from `PremiumDiscounts` /\n`PremiumTiers`. See [references/data-model.md](references/data-model.md) for\nthe full shape and every field.\n\n### Edge case: rejected pairing (client-side, no network call)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Crypto, // wrong method for a crypto side\n \"eth\",\n CurrencyType.Virtual,\n \"coins\",\n 1,\n);\n// res.ok === false, res.reason === \"client\" — rejected locally, no round-trip.\n// Use cryptoConvert instead.\n```\n\n### Edge case: not logged in\n\n```ts\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res.ok === false, res.reason === \"unauthorized\"\n```\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n client-side (the auto-generated `TransactionID`), so two separate calls are\n two real operations — a double-clicked \"Convert\" can charge twice. Disable\n the control while a call is in flight. (Firing the same endpoint again\n within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.)\n- **`convert` and `cryptoConvert` are not interchangeable.** `convert` is\n VC↔VC only (integer amounts); `cryptoConvert` requires at least one crypto\n side (decimal amounts) and rejects a VC↔VC pair. Both reject mismatched\n calls with `reason: \"client\"` before any network round-trip.\n- **Amount precision matters.** VC amounts are always integers — `convert`\n truncates via `Math.trunc`. Crypto amounts are decimal; pass a `Decimal` or\n numeric string for `cryptoConvert` rather than a JS `number` once you're\n near float precision limits (the SDK's own crypto math uses `decimal.js`\n throughout).\n- **Rounding always favors the house, never the player.** Both the VC↔VC and\n the crypto→VC paths truncate/floor the credited amount down — there is no\n \"round to nearest.\" A tiny source amount can legitimately convert to 0\n target units, which the backend rejects outright rather than granting a\n free-rounding credit.\n- **`RateApplied`/`FeeAmount` are informational, not something to\n precompute.** The backend enforces the title's configured\n `CurrencyConversion` rules (`Enabled`, `RateMode`, `FeePercent`, whitelisted\n `Targets` with their own `MinAmount`/`MaxAmount`/`DailyLimit`) — read the\n actual applied numbers off the response rather than estimating client-side.\n- **A conversion can fail on the currency's global limits even if the pair\n looks fine.** `Economy.MinBalance`/`MaxBalance`/`DailyEarnLimit`/\n `DailySpendLimit` apply on top of (and independently of) the pair-specific\n `MinAmount`/`MaxAmount`/`DailyLimit` — show whichever `error` string comes\n back rather than trying to pre-validate both layers yourself.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — the full\n`CurrencyDefinitions` config shape (virtual + crypto), the backend conversion\nformulas transcribed from source, and the complete, canonical documentation of\nthe shared `ResourceConsume` / `ResourceGrant` / `ResourceOperation` /\n`ResourceEntry` types used across the whole SDK. Read it before building\ncost/reward UI in any other module (Store offers, Character upgrades, Craft\nrecipes, Lootbox prices, Blockchain deposits/withdrawals all describe their\ncosts and payouts with these same shapes).\n",
|
|
4
|
+
"content": "---\nname: currency-system\ndescription: >-\n Convert between currencies in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.currency (CurrencyService): virtual-currency to\n virtual-currency (VC↔VC) conversion, and crypto-source conversion\n (crypto→VC or crypto→crypto). This is also the canonical home for the\n SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry\n cost-and-reward types used by every other module (Store, Character, Craft,\n Lootbox, Blockchain, …). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n currency-exchange screen, gold-to-gems conversion, crypto conversion, or\n otherwise touches client.currency, CurrencyService, ConvertResponse,\n CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or\n ResourceEntry — even if they don't name the module explicitly.\n---\n\n# Currency system (iDosGames TS SDK)\n\nThe Currency module is small — two methods — but it's the module every other\nsystem rides on: it's the reference implementation for converting one balance\ninto another, and it's the canonical home for the **shared cost/reward\nprimitives** (`ResourceConsume`, `ResourceGrant`, `ResourceOperation`,\n`ResourceEntry`) that Store, Character, Craft, Lootbox, Blockchain, and others\nall use to describe \"this action costs X and grants Y.\" Read this skill once\nand the resource shapes in every other module's docs make sense by reference.\n\nEverything is **server-authoritative**: the client asks the backend to\nconvert, the backend validates status/rate/fee/limits and debits/credits, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate balances yourself, and you never precompute the rate, fee, or\nrounding client-side — the backend owns all of it.\n\nThis skill is for **using** the production `CurrencyService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(disabled conversion, unlisted target, currency under maintenance, daily\nlimit, insufficient funds) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Two currency kinds\n\n- **Virtual currency (VC)** — title-defined, integer balances (gold, gems,\n energy). Config: `VirtualCurrencyDefinition`.\n- **Crypto currency** — on-chain-backed, decimal balances (ETH, USDT, …).\n Config: `CryptoCurrencyDefinition`. Crypto balances/deposits/withdrawals are\n otherwise the Blockchain module's territory — see the blockchain-system\n skill; Currency only covers converting a crypto balance you already hold.\n\nBoth currency kinds share a `CurrencyType` tag (`\"Virtual\"` | `\"Crypto\"`) used\nthroughout requests/responses to disambiguate `CurrencyID`s that could\notherwise collide.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst currency = client.currency; // the CurrencyService\n```\n\nEvery currency method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the 600ms throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Conversion from\n'X' is disabled\", \"is under maintenance\", \"is deprecated and cannot receive\nnew credits\", \"Amount below pair minimum\", \"Per-pair daily limit exceeded\",\ninsufficient balance).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------- |\n| `convert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | VC↔VC conversion only. Integer amounts. | `ConvertResponse` |\n| `cryptoConvert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | Crypto-**source** conversion (crypto→VC or crypto→crypto). Decimal amounts. | `CryptoConvertResponse` |\n\n`sourceAmount` for `convert` is a `number` (truncated to an integer via\n`Math.trunc` before sending — VC balances are integer). For `cryptoConvert` it\nis `Decimal | string | number` (a `decimal.js` `Decimal`, a numeric string, or\na number) — pass a string or `Decimal` for anything beyond safe-integer/float\nprecision; the SDK depends on `decimal.js`. Each method validates locally\nbefore any network call and rejects mismatched pairs:\n\n- `convert` rejects if either side is `CurrencyType.Crypto` — \"Convert\n supports VC↔VC only. Use cryptoConvert for crypto.\"\n- `cryptoConvert` rejects if **both** sides are `CurrencyType.Virtual` —\n \"cryptoConvert requires at least one crypto side. Use convert for VC↔VC.\"\n- Both reject source === target, empty IDs, non-positive amounts.\n- `cryptoConvert` additionally rejects a non-integer amount when the source\n side is `Virtual` (VC amounts must be whole) — \"Virtual currency source\n amount must be integer.\"\n\nOne server-side constraint the SDK preflight does **not** catch: the backend's\n`CryptoConvert` endpoint requires the **source** to be `Crypto`, and\n`Virtual→Crypto` is not supported by any endpoint (`Convert` explicitly fails\nVirtual→Crypto with \"Virtual→Crypto conversion is not supported.\"). So the\nonly valid pairings are `convert` for VC→VC and `cryptoConvert` for crypto→VC\n/ crypto→crypto; a Virtual-source `cryptoConvert` passes the local check but\ncomes back `reason: \"server\"` (\"CryptoConvert requires source to be Crypto.\nUse Convert for VC↔VC.\").\n\n`transactionID` is optional; omit it and the SDK generates a unique one per\ncall (`convert_<sourceType>_<sourceID>_to_<targetType>_<targetID>_<uuid>` /\n`crypto_convert_...`). The backend folds `TransactionID` into its idempotency\nkey (`CurrencyConvert:<key>` / `CurrencyCryptoConvert:<key>`, where `<key>` is\n`TransactionID` verbatim when you supply one), stored per `(userID, reason)`\nfor 7 days. **Retrying with the same `transactionID` is safe** — the server\ndetects the replay and returns the stored result instead of charging again.\nTwo calls with different (e.g. auto-generated) IDs are two real conversions.\n\nOn success, both methods **mirror the confirmed debit/credit into the cache\nand emit an event** — you don't apply anything by hand. Read updated balances\nstraight from the cache.\n\n### Non-obvious server behavior worth knowing before you build UI\n\n- **Rate resolution**: `Automatic` mode divides the source's `ValueInUSD` by\n the target's `ValueInUSD` (`rate = src.ValueInUSD / tgt.ValueInUSD`);\n `Manual` mode uses the fixed `Rate` pinned on that specific\n `ConversionTarget`. Which mode applies is a property of the **source**\n currency (`Conversion.RateMode`), not the pair.\n- **Fee comes off first, then the rate, then rounding**: `fee = sourceAmount *\nFee`; `net = sourceAmount - fee`; `output = net * rate`. Order matters for\n previews.\n- **`Fee` is a SHARE, not a percent**: `0.05` means 5%. This is the unit used\n across the whole platform (`RateSpec`), and the value is passed straight into\n `RateSpec.Rate` with no rescaling — there is no division by 100 anywhere. The\n field is deliberately not called `FeePercent`; the `Percent` suffix is banned\n platform-wide because it was the source of 0..1 vs 0..100 confusion. Show\n percents in a UI by multiplying by 100. A `Fee` of `1.0` is a deliberate ban\n on converting that currency, and is not clamped away.\n- **Rounding is always truncation toward zero, never nearest/ceiling.** VC↔VC\n truncates the final `output` to a `long`. Crypto-source conversions keep\n full decimal precision throughout _except_ when the target is Virtual, where\n the decimal `output` is floored (`Math.Floor`) to a `long`. A conversion\n whose result rounds to 0 (dust) is rejected rather than silently granting\n nothing.\n- **Currency status gates asymmetrically**: `Maintenance` blocks the currency\n on either side; `Deprecated` blocks it only as a **target** (can't receive\n new credits) — a deprecated currency can still be spent down as a _source_.\n- **Two independent limit layers can reject the same call**: the per-pair\n `ConversionTarget.MinAmount` / `MaxAmount` / `DailyLimit` (scoped to this\n exact source→target pair), and the source currency's own\n `Economy.MinBalance` / `MaxBalance` / `DailyEarnLimit` / `DailySpendLimit`\n (scoped to the whole currency, across every way it can change). Either can\n fail independently — don't assume passing one means the other passed.\n\n## Reading state and reacting to changes\n\n```ts\n// Virtual currency balance (integer):\nclient.data.user.getVirtualCurrencyAmount(\"coins\"); // number\n\n// Crypto currency balance (decimal-as-string):\nclient.data.user.getCryptoCurrencyAmount(\"eth\"); // string, e.g. \"0.05\"\n\n// Currency catalog (config). Populated by client.title.getCurrencyDefinitions()\n// (emits \"title:currencyDefinitionsReceived\"), with a fallback to the `Currency`\n// section of the full title public configuration if that's been loaded:\nconst defs = client.data.config.currencyDefinitions; // CurrencyDefinitions | undefined\ndefs?.VirtualCurrencies?.[\"coins\"]?.Conversion?.Targets;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `currency:converted` → `ConvertResponse`\n- `currency:cryptoConverted` → `CryptoConvertResponse`\n\nNeither `convert` nor `cryptoConvert` has its own coarse `user:currencyUpdated`\nevent — the balance change instead rides through the same resource pipeline\nevery other module uses. `convert` (VC↔VC) always applies through the integer\nresource pipeline, so it fires `user:virtualCurrencyUpdated` +\n`user:inventoryUpdated` (plus the umbrella `user:anyUpdated`) for both sides.\n`cryptoConvert` splits by side: a `Virtual` leg goes through the same\nresource pipeline (same events as above for that leg only); a `Crypto` leg\ngoes through a separate decimal patch that fires only\n`user:inventoryUpdated` + `user:anyUpdated` (no\n`user:virtualCurrencyUpdated`, since no VC balance changed). Listen at\nwhichever granularity suits your UI: the specific `currency:*` event for a\ntoast/confirmation, the coarse `user:*` event for a \"re-render balances\" hook.\n\n```ts\nconst off = client.on(\"currency:converted\", (r) => {\n console.log(\n `Spent ${r.SourceSpent} ${r.SourceID} -> got ${r.TargetCredited} ${r.TargetID}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Convert gold to gems (VC↔VC)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // 100 (integer, includes the fee)\nres.data.FeeAmount; // e.g. 10 (integer, source-currency units — 10% fee here)\nres.data.TargetCredited; // e.g. 45 (integer: (100 - 10) * 0.5, truncated)\nres.data.RateApplied; // decimal-as-string, e.g. \"0.5\"\n// Balances are already updated in the cache:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Convert a crypto balance to virtual currency\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\nimport Decimal from \"decimal.js\";\n\nconst res = await client.currency.cryptoConvert(\n CurrencyType.Crypto,\n \"usdt\",\n CurrencyType.Virtual,\n \"gems\",\n new Decimal(\"2.50\"), // decimal source amount\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // \"2.50\" (decimal string)\nres.data.TargetCredited; // \"500\" (decimal string; VC side is still integer-valued and floored)\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Read a cost/reward breakdown from another module's response\n\nCurrency doesn't return `ResourceOperation` itself (its responses are flat\n`ConvertResponse`/`CryptoConvertResponse`), but almost every other module's\nresponse embeds one under a `Resources` field — e.g. a Store purchase, a\nCharacter upgrade, a Blockchain deposit. Once you've read this skill you can\nread any of them the same way:\n\n```ts\nimport type { ResourceOperation } from \"@idosgames/core\";\n\nfunction summarize(op: ResourceOperation | null | undefined) {\n const spent = op?.Consume?.Standard?.Entries ?? [];\n const gained = op?.Grant?.Standard?.Entries ?? [];\n for (const e of spent)\n console.log(`-${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n for (const e of gained)\n console.log(`+${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n}\n```\n\n`Standard` is already the server-resolved final amount (premium\ndiscounts/bonuses folded in) — don't re-derive it from `PremiumDiscounts` /\n`PremiumTiers`. See [references/data-model.md](references/data-model.md) for\nthe full shape and every field.\n\n### Edge case: rejected pairing (client-side, no network call)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Crypto, // wrong method for a crypto side\n \"eth\",\n CurrencyType.Virtual,\n \"coins\",\n 1,\n);\n// res.ok === false, res.reason === \"client\" — rejected locally, no round-trip.\n// Use cryptoConvert instead.\n```\n\n### Edge case: not logged in\n\n```ts\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res.ok === false, res.reason === \"unauthorized\"\n```\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n client-side (the auto-generated `TransactionID`), so two separate calls are\n two real operations — a double-clicked \"Convert\" can charge twice. Disable\n the control while a call is in flight. (Firing the same endpoint again\n within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.)\n- **`convert` and `cryptoConvert` are not interchangeable.** `convert` is\n VC↔VC only (integer amounts); `cryptoConvert` requires at least one crypto\n side (decimal amounts) and rejects a VC↔VC pair. Both reject mismatched\n calls with `reason: \"client\"` before any network round-trip.\n- **Amount precision matters.** VC amounts are always integers — `convert`\n truncates via `Math.trunc`. Crypto amounts are decimal; pass a `Decimal` or\n numeric string for `cryptoConvert` rather than a JS `number` once you're\n near float precision limits (the SDK's own crypto math uses `decimal.js`\n throughout).\n- **Rounding always favors the house, never the player.** Both the VC↔VC and\n the crypto→VC paths truncate/floor the credited amount down — there is no\n \"round to nearest.\" A tiny source amount can legitimately convert to 0\n target units, which the backend rejects outright rather than granting a\n free-rounding credit.\n- **`RateApplied`/`FeeAmount` are informational, not something to\n precompute.** The backend enforces the title's configured\n `CurrencyConversion` rules (`Enabled`, `RateMode`, `Fee`, whitelisted\n `Targets` with their own `MinAmount`/`MaxAmount`/`DailyLimit`) — read the\n actual applied numbers off the response rather than estimating client-side.\n- **A conversion can fail on the currency's global limits even if the pair\n looks fine.** `Economy.MinBalance`/`MaxBalance`/`DailyEarnLimit`/\n `DailySpendLimit` apply on top of (and independently of) the pair-specific\n `MinAmount`/`MaxAmount`/`DailyLimit` — show whichever `error` string comes\n back rather than trying to pre-validate both layers yourself.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — the full\n`CurrencyDefinitions` config shape (virtual + crypto), the backend conversion\nformulas transcribed from source, and the complete, canonical documentation of\nthe shared `ResourceConsume` / `ResourceGrant` / `ResourceOperation` /\n`ResourceEntry` types used across the whole SDK. Read it before building\ncost/reward UI in any other module (Store offers, Character upgrades, Craft\nrecipes, Lootbox prices, Blockchain deposits/withdrawals all describe their\ncosts and payouts with these same shapes).\n",
|
|
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, 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"
|
|
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 MinWithdraw?: 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 Fee?: number; // SHARE of the debited amount: 0.05 = 5% (never 0..100)\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**: `Fee` is a SHARE (`0.05` = 5%), so `feeAmount = sourceAmount * Fee`\n — there is no division by 100. It is **not** clamped to a range: only `NaN`\n is treated as zero, and the fee is capped at the source amount itself. On the\n VC→VC path a fee below `1.0` additionally leaves at least one unit\n (`MaxPerPosition = sourceAmount - 1`), so that rounding alone can never make\n a small conversion impossible; `Fee = 1.0` is a deliberate ban and keeps its\n full effect. `netSource = sourceAmount - feeAmount`; `netSource <= 0` is\n 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
|
}
|