@idosgames/mcp 0.1.5 → 0.1.6
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/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +19 -15
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/character-system.json +2 -2
- package/registry/skills/checkout-system.json +6 -0
- package/registry/skills/collection-system.json +2 -2
- package/registry/skills/coop-event-system.json +2 -2
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/deal-offer-system.json +2 -2
- package/registry/skills/game-loop-system.json +1 -1
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/localization-system.json +1 -1
- package/registry/skills/lootbox-system.json +2 -2
- package/registry/skills/marketplace-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +2 -2
- package/registry/skills/referral-system.json +1 -1
- package/registry/skills/season-system.json +1 -1
- package/registry/skills/store-system.json +2 -2
- package/registry/skills/timed-boost-system.json +2 -2
- package/registry/skills/tutorial-system.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idosgames/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/registry/host.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"path": "package.json",
|
|
14
|
-
"content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.1.
|
|
14
|
+
"content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.1.13\",\n \"@idosgames/core\": \"0.7.0\",\n \"@idosgames/module-sdk\": \"0.1.8\",\n \"@idosgames/react\": \"0.2.0\",\n \"@idosgames/wallet\": \"0.2.0\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"path": "src/config.ts",
|
package/registry/index.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedFromCommit": "
|
|
2
|
+
"generatedFromCommit": "f160bbae4de0d0e10b1b41d4f02a6c47ffd34fe3",
|
|
3
3
|
"runtimePackages": {
|
|
4
|
-
"@idosgames/core": "0.
|
|
5
|
-
"@idosgames/wallet": "0.
|
|
6
|
-
"@idosgames/module-sdk": "0.1.
|
|
7
|
-
"@idosgames/react": "0.
|
|
8
|
-
"@idosgames/app-shell": "0.1.
|
|
4
|
+
"@idosgames/core": "0.7.0",
|
|
5
|
+
"@idosgames/wallet": "0.2.0",
|
|
6
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
7
|
+
"@idosgames/react": "0.2.0",
|
|
8
|
+
"@idosgames/app-shell": "0.1.13"
|
|
9
9
|
},
|
|
10
10
|
"host": {
|
|
11
11
|
"id": "host-starter",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"version": "0.1.0",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@idosgames/core": "0.
|
|
50
|
-
"@idosgames/module-sdk": "0.1.
|
|
51
|
-
"@idosgames/react": "0.
|
|
52
|
-
"@idosgames/wallet": "0.
|
|
49
|
+
"@idosgames/core": "0.7.0",
|
|
50
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
51
|
+
"@idosgames/react": "0.2.0",
|
|
52
|
+
"@idosgames/wallet": "0.2.0",
|
|
53
53
|
"@tanstack/react-query": "5.101.2",
|
|
54
54
|
"react": "19.2.7",
|
|
55
55
|
"three": "0.185.1",
|
|
@@ -92,10 +92,10 @@
|
|
|
92
92
|
},
|
|
93
93
|
"version": "0.1.0",
|
|
94
94
|
"dependencies": {
|
|
95
|
-
"@idosgames/core": "0.
|
|
96
|
-
"@idosgames/module-sdk": "0.1.
|
|
97
|
-
"@idosgames/react": "0.
|
|
98
|
-
"@idosgames/wallet": "0.
|
|
95
|
+
"@idosgames/core": "0.7.0",
|
|
96
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
97
|
+
"@idosgames/react": "0.2.0",
|
|
98
|
+
"@idosgames/wallet": "0.2.0",
|
|
99
99
|
"@tanstack/react-query": "5.101.2",
|
|
100
100
|
"phaser": "4.2.1",
|
|
101
101
|
"react": "19.2.7",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
"version": "0.1.0",
|
|
140
140
|
"dependencies": {
|
|
141
|
-
"@idosgames/module-sdk": "0.1.
|
|
141
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
142
142
|
"three": "0.185.1"
|
|
143
143
|
},
|
|
144
144
|
"fileCount": 38
|
|
@@ -157,6 +157,10 @@
|
|
|
157
157
|
"name": "character-system",
|
|
158
158
|
"description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly."
|
|
159
159
|
},
|
|
160
|
+
{
|
|
161
|
+
"name": "checkout-system",
|
|
162
|
+
"description": "Take payment for anything priced in a game on the iDosGames TypeScript SDK: price options (PriceOptions), real-money purchases through Google Play / App Store (client.purchase, PurchaseService), paying with an on-chain currency from the player's wallet (@idosgames/wallet payWithWalletEvm), and choosing what to show per platform (client.checkout, CheckoutService, usePayment). Use this whenever the user works in the iDosGames TS SDK and touches prices, IAP, receipts, store products, \"pay with crypto\", platform gating of payment methods, PriceOption, PaymentProof, SelectedOptionID, or asks why an option is missing or a purchase is refused — even if they don't name the module."
|
|
163
|
+
},
|
|
160
164
|
{
|
|
161
165
|
"name": "cloud-code",
|
|
162
166
|
"description": "Write and call custom server-side game logic on the iDosGames platform: author a CloudCode handler (sandboxed JavaScript with a server.* API) and invoke it from the game via client.cloudCode (CloudCodeService) with an arbitrary JSON payload. Use this whenever the user wants bespoke server logic, a \"cloud script\", \"cloud function\" or \"server callable\", logic that must be authoritative (granting rewards, validating a reported result, anti-cheat), a write to protected player data (UserCustomData ReadOnly / Internal buckets) or to shared title state (TitleCustomData Runtime scope), or otherwise touches client.cloudCode, CloudCodeService, handlers, server.SetUserCustomData, server.IncrementTitleCustomData, server.HttpRequest or ExecuteCloudCodeResponse — even if they don't name the module explicitly. Also covers integrating a title with a third-party service (calling an external API with a stored API key, webhooks out, payment or analytics providers) and the {{secret:NAME}} / {{var:NAME}} placeholders."
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.
|
|
43
|
-
"@idosgames/wallet": "0.
|
|
40
|
+
"@idosgames/core": "0.7.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
42
|
+
"@idosgames/react": "0.2.0",
|
|
43
|
+
"@idosgames/wallet": "0.2.0",
|
|
44
44
|
"@tanstack/react-query": "5.101.2",
|
|
45
45
|
"react": "19.2.7",
|
|
46
46
|
"three": "0.185.1",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.
|
|
43
|
-
"@idosgames/wallet": "0.
|
|
40
|
+
"@idosgames/core": "0.7.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
42
|
+
"@idosgames/react": "0.2.0",
|
|
43
|
+
"@idosgames/wallet": "0.2.0",
|
|
44
44
|
"@tanstack/react-query": "5.101.2",
|
|
45
45
|
"phaser": "4.2.1",
|
|
46
46
|
"react": "19.2.7",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "character-system",
|
|
3
3
|
"description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\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 characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"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. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID)` | Buy/unlock a locked character (charges its `Unlock.Cost`). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.Cost and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has an `Unlock.Cost` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no `Cost` and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again 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.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\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 on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
|
|
4
|
+
"content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\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 characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"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. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID, options?)` | Buy/unlock a locked character (charges the selected `Unlock.PriceOptions` option). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.PriceOptions and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\n// Third argument picks the way to pay and carries a store receipt when the option needs one.\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has `Unlock.PriceOptions` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no options and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\nWhen the character has several ways to pay, render them with\n`client.checkout.availableOptions(def.Unlock.PriceOptions)` and pass the chosen one:\n\n```ts\nawait client.character.unlockCharacter(\"Knight\", {\n selectedOptionID: option.OptionID,\n // required only when this option is paid in a store (a `Purchase` entry in its Cost)\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again 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.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\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 on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterLevelDefinition`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterLevelDefinition (ranks)](#characterleveldefinition-ranks)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<\n string,\n { Levels?: Record<string, CharacterLevelDefinition> }\n >;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n Cost?: ResourceConsume; // null/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n Levels?: Record<string, CharacterLevelDefinition>; // \"1\",\"2\",... -> rank\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `Cost` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `Cost` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n BaseCostResource: ResourceConsume; // cost of level 1 (must be non-empty)\n CostScalingFactor: number; // linear cost growth per level\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n StatScalingFactor: number; // flat value added per stat level (additive, not a %)\n CharacterLevelScalingFactor: number; // fractional per-rank value growth (multiplicative)\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nFormulas (the backend applies these; use them for previews):\n\n- **Cost of level `N`** (N = target level, 1-based): each entry's base `Amount`\n scaled by `Amount * (1 + CostScalingFactor * (N - 1))`, rounded to the nearest\n whole amount.\n- **Effective max level** = `floor(MaxLevel * StatMaxLevelMultiplier)` where the\n multiplier comes from the character's _current_ rank\n (`CharacterLevelDefinition.StatMaxLevelMultiplier`, default 1). This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** =\n `(BaseStatValue + N * StatScalingFactor) * (1 + CharacterLevelScalingFactor * (R - 1))`.\n The stat part grows **additively** (`N = 0` before the first upgrade, so the\n base value is what a fresh character has); the rank part multiplicatively.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\n`Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterLevelDefinition (ranks)\n\nConfig for one character Level/rank. Key in `Levels` is the level number as a\nstring (`\"1\"`, `\"2\"`, ...). Level `0` = uninitialized, has no config.\n\n```ts\ninterface CharacterLevelDefinition {\n Level: number;\n UpgradeCost?: ResourceConsume; // cost to reach this level; absent = free\n GlobalStatMultiplier?: number; // multiplies the character's stat values (and thus Power) at this rank (1.0 = none)\n StatMaxLevelMultiplier?: number; // raises every stat's cap at this rank\n AssetPaths?: Record<string, string>;\n}\n```\n\n`upgradeCharacterLevel` moves the character from its current level to the next\none and charges that level's `UpgradeCost`.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the rank's `GlobalStatMultiplier`;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
|
|
8
|
+
"content": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterLevelDefinition`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterLevelDefinition (ranks)](#characterleveldefinition-ranks)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<\n string,\n { Levels?: Record<string, CharacterLevelDefinition> }\n >;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n PriceOptions?: Record<string, PriceOption>; // empty/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n Levels?: Record<string, CharacterLevelDefinition>; // \"1\",\"2\",... -> rank\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `PriceOptions` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `PriceOptions` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n## Prices are `PriceOptions`, not a single cost\n\nEvery price in this module is a **dictionary of payment options** (`PriceOptions`), keyed by\n`OptionID`. One option = one way to pay; the entries inside an option's `Cost` are charged\ntogether. Pick one with `SelectedOptionID`, or omit it and the server takes the first option\navailable on the caller's platform — which is why a single-price character needs no client change.\n\nRender options through `client.checkout.availableOptions(...)`: an option may be restricted to\nsome platforms (`AllowedPlatforms`), and an option whose `Cost` holds a `Purchase` entry is paid\nwith a **store receipt**, not from balances. Unlock accepts store payment; stat and rank upgrades\ndo not — their price grows by a formula per level, and a store SKU is a fixed tier. See the\n`checkout-system` skill.\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n PriceOptions: Record<string, PriceOption>; // ways to pay for level 1 (must be non-empty)\n CostScalingFactor: number; // linear cost growth per level\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n StatScalingFactor: number; // flat value added per stat level (additive, not a %)\n CharacterLevelScalingFactor: number; // fractional per-rank value growth (multiplicative)\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nFormulas (the backend applies these; use them for previews):\n\n- **Cost of level `N`** (N = target level, 1-based): each entry's base `Amount`\n scaled by `Amount * (1 + CostScalingFactor * (N - 1))`, rounded to the nearest\n whole amount.\n- **Effective max level** = `floor(MaxLevel * StatMaxLevelMultiplier)` where the\n multiplier comes from the character's _current_ rank\n (`CharacterLevelDefinition.StatMaxLevelMultiplier`, default 1). This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** =\n `(BaseStatValue + N * StatScalingFactor) * (1 + CharacterLevelScalingFactor * (R - 1))`.\n The stat part grows **additively** (`N = 0` before the first upgrade, so the\n base value is what a fresh character has); the rank part multiplicatively.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\nAn option's `Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterLevelDefinition (ranks)\n\nConfig for one character Level/rank. Key in `Levels` is the level number as a\nstring (`\"1\"`, `\"2\"`, ...). Level `0` = uninitialized, has no config.\n\n```ts\ninterface CharacterLevelDefinition {\n Level: number;\n PriceOptions?: Record<string, PriceOption>; // ways to pay for this level; empty/absent = free\n GlobalStatMultiplier?: number; // multiplies the character's stat values (and thus Power) at this rank (1.0 = none)\n StatMaxLevelMultiplier?: number; // raises every stat's cap at this rank\n AssetPaths?: Record<string, string>;\n}\n```\n\n`upgradeCharacterLevel` moves the character from its current level to the next\none and charges the selected option of that level's `PriceOptions`.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the rank's `GlobalStatMultiplier`;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "checkout-system",
|
|
3
|
+
"description": "Take payment for anything priced in a game on the iDosGames TypeScript SDK: price options (PriceOptions), real-money purchases through Google Play / App Store (client.purchase, PurchaseService), paying with an on-chain currency from the player's wallet (@idosgames/wallet payWithWalletEvm), and choosing what to show per platform (client.checkout, CheckoutService, usePayment). Use this whenever the user works in the iDosGames TS SDK and touches prices, IAP, receipts, store products, \"pay with crypto\", platform gating of payment methods, PriceOption, PaymentProof, SelectedOptionID, or asks why an option is missing or a purchase is refused — even if they don't name the module.",
|
|
4
|
+
"content": "---\nname: checkout-system\ndescription: >-\n Take payment for anything priced in a game on the iDosGames TypeScript SDK:\n price options (PriceOptions), real-money purchases through Google Play /\n App Store (client.purchase, PurchaseService), paying with an on-chain\n currency from the player's wallet (@idosgames/wallet payWithWalletEvm), and\n choosing what to show per platform (client.checkout, CheckoutService,\n usePayment). Use this whenever the user works in the iDosGames TS SDK and\n touches prices, IAP, receipts, store products, \"pay with crypto\", platform\n gating of payment methods, PriceOption, PaymentProof, SelectedOptionID, or\n asks why an option is missing or a purchase is refused — even if they don't\n name the module.\n---\n\n# Checkout (iDosGames TS SDK)\n\nEverything a title sells is priced the same way: a **`PriceOptions` dictionary**\non the entity, where each option is one way to pay. The key of the dictionary is\nthe option's `OptionID`, and the player picks one.\n\nOptions exist because a single cost is charged **as a whole** — that is \"and\".\nA price across platforms is a **choice** — \"or\": a store SKU on mobile, a token\non web. One option, one way to pay.\n\n```ts\n// An offer with two ways to pay.\nPriceOptions: {\n gold: { OptionID: \"gold\", Cost: { Standard: { Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"gold\", Amount: 500 }] } } },\n store: { OptionID: \"store\", Cost: { Standard: { Entries: [{ Type: \"Purchase\", ProductID: \"coin_pack_small\" }] } },\n AllowedPlatforms: [\"Android\", \"Ios\"] },\n}\n```\n\n## The one rule that matters\n\n**The client renders, the server allows.**\n\nTitle config ships as one immutable artifact per (version, field set), so it\n_cannot_ be filtered per request — every platform receives the same price list.\nSo the client hides what it may not offer, and the server refuses to charge an\noption that is not available on the caller's platform. Showing an option a store\nforbids is a policy problem; paying with it is impossible either way.\n\nThat is why you never hand-roll the filter:\n\n```ts\nconst options = client.checkout.availableOptions(offer.PriceOptions);\n```\n\n## Paying\n\nWhich of the three shapes you are in is answered by one call:\n\n```ts\nconst requirement = client.checkout.requirementOf(option);\n// { kind: \"store\", productID } → buy the SKU, then pass the receipt\n// { kind: \"resources\" } → just call the module\n// { kind: \"resources\", shortfall } → crypto price, player is short\n```\n\n### 1. Resources (currency, items, tokens)\n\nNothing special — call the module that owns the entity:\n\n```ts\nawait client.store.purchase(offerID, count); // SelectedOptionID defaults to the first available\n```\n\nPass `SelectedOptionID` explicitly whenever the player chose an option — the\ndefault only exists so a single-price entity keeps working without any client\nchange.\n\n### 2. Real money (Google Play / App Store)\n\nThe receipt comes from the store SDK on the device; the SDK never talks to a\nstore itself. Attach it to the purchase:\n\n```ts\nawait client.store.purchase(offerID, count, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n⚠ **The money is already gone when the server first hears about it.** A refusal\nhere is an incident, not \"insufficient funds\" — the server records every refusal\nin the title's transaction ledger together with what the receipt was paying for.\nTwo consequences for your code:\n\n- **Never drop a receipt.** Hand every receipt the store gives you to the server,\n including the unfinished ones it re-delivers on startup. An order confirmed to\n the store but never delivered to us is a purchase the player paid for and will\n never receive.\n- **Re-submitting the same receipt is safe** and grants nothing twice — but the\n response then carries no resources. Apply `Resources` only when the server says\n it granted something (`Granted: true` for `client.purchase`, a non-empty\n `Resources` for module purchases).\n\nFor products that ARE the goods (gem packs, remove-ads, subscriptions) use\n`client.purchase` directly — `validatePurchase`, `validatePurchasesBatch` for\nrestore after a reinstall.\n\n### 3. On-chain currency from the player's wallet\n\nThe price is denominated in tokens, and the player pays from their in-game\nbalance. When that balance is short, top it up from their own wallet — this\nlives in `@idosgames/wallet`, because `core` never imports a wallet stack:\n\n```ts\nimport { payWithWalletEvm } from \"@idosgames/wallet\";\n\nconst { shortfall } = client.checkout.requirementOf(option);\nif (shortfall) {\n await payWithWalletEvm({\n client,\n clients,\n network,\n tokenAddress,\n decimals,\n titleID,\n shortfall,\n completePurchase: () =>\n client.store.purchase(offerID, count, {\n selectedOptionID: option.OptionID,\n }),\n });\n}\n```\n\n### Host owns the session (Unity WebGL, native shells)\n\nWhen the wallet lives on the page but the game's session does not — a Unity WebGL build is the\ncase — use `depositTokenOnChainEvm` instead. It does only the on-chain half (approve → deposit →\nwait for the receipt) and reports nothing to the backend; the host registers the tx hash with its\nown session and then makes the purchase. Handing the page a logged-in client instead would give\nevery script on it full access to the player's account.\n\n⚠ **This is two steps and cannot be one.** A deposit confirms on-chain, so the\ntokens land on the balance in one request and the purchase happens in the next.\nNothing is lost if the game dies in between — the tokens are credited — but the\nplayer is left with money and no goods unless you can resume. **Keep the intent\nlocally and offer to finish it on the next launch.**\n\nOnly the **shortfall** is deposited, not the whole price: whatever the player\nalready holds in-game is spent first.\n\n## React\n\n`usePayment` owns the part every payment UI repeats — which options to show,\nwhat each needs, and which stage the attempt is at:\n\n```tsx\nconst { options, requirementOf, stage, error, pay } = usePayment(\n offer.PriceOptions,\n);\n```\n\n`stage` is a value (`awaiting-store`, `awaiting-wallet`, `purchasing`, …) rather\nthan a boolean on purpose: a wallet payment takes tens of seconds while the\ndeposit confirms, and a dialog showing only \"loading\" makes a working payment\nlook hung — so the player taps again.\n\n## Where a price may NOT be paid in a store\n\nSome prices are resource-only, and the server says so explicitly rather than\nfailing deep inside the resource engine:\n\n| Where | Why |\n| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| Item / character / stat upgrades | Price grows by a formula per level; a store SKU is a fixed tier and cannot follow it |\n| Craft | Price is per craft and multiplied by the count — there is no \"one and a half receipts\" |\n| Coop-event spins | Only the spins that actually happened are charged; a store cannot refund half a receipt |\n| Match creation fee, marketplace listing fee | May have to be refunded, and a store refund is not ours to issue — plus these are player-to-player contexts |\n| AI generation | A rate-limited micro-action; one receipt cannot be split across many of them |\n\nDeal offers, store offers, lootboxes, collection packs, character unlocks and\ntimed boosts **do** accept store payment — that is the main monetisation path.\n\n## Common mistakes\n\n- **Filtering options yourself by `AllowedPlatforms`.** Use\n `client.checkout.availableOptions` — it also applies the same ordering the\n server uses when the player did not choose an option.\n- **Trusting `PriceUsdCents` for display on mobile.** Show the store's own\n localized price; the declared one is for analytics and sorting, and both\n platforms require their price to be the one displayed.\n- **Applying `Resources` from a repeated receipt.** See above — that credits the\n reward twice for a single payment.\n- **Assuming an option exists because the config has it.** On a platform where it\n is not allowed, the server refuses the charge; render from\n `availableOptions`, not from the raw dictionary.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "collection-system",
|
|
3
3
|
"description": "Build a collection / sticker-album / TCG system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.collection (CollectionService): open collectible packs and pity-driven collection chests, spend \"joker\" wildcards to fill a specific slot, claim set-completion rewards (single + batch) and the collection Grand Prize, and run peer-to-peer collectible trading (send/cancel/accept/decline trade offers, list my/incoming offers). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a sticker album, TCG-style collection/set-completion screen, pack-opening UI, duplicate/pity systems, or player-to-player item trading — or otherwise touches client.collection, CollectionService, CollectionDefinitions, UserCollectionState, or trade offers — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: collection-system\ndescription: >-\n Build a collection / sticker-album / TCG system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.collection (CollectionService):\n open collectible packs and pity-driven collection chests, spend \"joker\"\n wildcards to fill a specific slot, claim set-completion rewards (single +\n batch) and the collection Grand Prize, and run peer-to-peer collectible\n trading (send/cancel/accept/decline trade offers, list my/incoming offers).\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a sticker album, TCG-style\n collection/set-completion screen, pack-opening UI, duplicate/pity systems,\n or player-to-player item trading — or otherwise touches client.collection,\n CollectionService, CollectionDefinitions, UserCollectionState, or trade\n offers — even if they don't name the module explicitly.\n---\n\n# Collection system (iDosGames TS SDK)\n\nThe Collection module is a \"sticker album\": a title defines one or more\n**Collections**, each made of thematic **Sets** (\"pages\"), each Set made of\n**Collectibles** (\"stickers\", each optionally with a rarer Special version).\nPlayers fill the album by opening **Packs** (lootboxes) and **Collection\nChests** (pity-driven, bought with Collection Currency earned from\nduplicates), can burn a **Joker** wildcard to fill one specific missing\nCollectible, claim a reward when a Set is completed, and claim a **Grand\nPrize** when the whole Collection is completed. A separate **trading**\nsub-system lets players swap Collectibles peer-to-peer.\n\nEverything is **server-authoritative**, same contract as the rest of the SDK:\ncall a method, check `result.ok`, render from the mirrored cache. This skill\nis for **using** the production `CollectionService`, not porting or extending\nit — a rejection is the backend enforcing a rule, surface the error rather\nthan reproducing the check client-side.\n\nThis module frequently sits next to [item-system](../item-system/SKILL.md) or\ncharacter loadouts — Collectibles are a separate currency-and-progress track\nfrom `client.item`/`client.character`, not items themselves, though a title\nmay reward items via `SetCompletionReward` / `GrandPrize`.\n\n## The two data shapes\n\n1. **Definitions** (config) — the title's catalog: `Collections` (each with\n `Sets`, each with `Collectibles`), `PackTypes` (lootbox-style openable\n packs), `CollectionChests` (pity-buy chests priced in Collection\n Currency), `DuplicateConversions` (duplicate → currency rate by rarity),\n `DailyTradeLimit`, the joker's `CollectibleJokerCatalogID` /\n `CollectibleJokerItemID`, and `SpecialTradeEvents` (time windows that\n unlock Special-collectible trading). Fetched with `getDefinitions()`.\n2. **User state** (state, per player) — `CollectionCurrencyBalance`,\n `OwnedCollectibles` / `OwnedSpecialCollectibles` (id → count),\n `ClaimedSetRewards`, `IsCollectionCompleted`, `GrandPrizeClaimed`,\n `DailyTradesSent` (+ reset date), `PendingTradeOfferIDs`, and pity\n `PityCounters`. Fetched with `getUserState()`. **This state object is\n stored wholesale in the cache and typed leniently (`Record`-style\n passthrough)** — read fields defensively (`?.`), don't assume every field\n is always present.\n\nFor the full field-by-field shape, formulas for duplicate conversion, and the\ntrade-offer document shape, read\n[references/data-model.md](references/data-model.md).\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 collection = client.collection; // the CollectionService\n```\n\nEvery method requires an authenticated session; without one they return\n`{ ok: false, reason: \"unauthorized\" }` — none of them throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: `{ ok: true, data }` or\n`{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is one\nof `\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (same\nendpoint fired again inside the 600ms default window), `\"connection\"`\n(transient — offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` has the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's collection catalog (config). | `CollectionDefinitions` |\n| `getUserState()` | Load this player's collection progress (state). | `UserCollectionState` |\n| `openPack(collectionID, packTypeID, count?)` | Open `count` packs (default 1) in one atomic call; cost scales with the count. `count` is clamped server-side to the pack type's `MaxOpenCount` → module `Settings.MaxPackOpenCount` → platform default (100). Read `OpenedCount` for what actually happened and `Packs` for the per-pack breakdown. | `OpenPackResponse` |\n| `openCollectionChest(collectionID, collectionChestID, count?)` | Open `count` pity chests (default 1) in one atomic call; cost scales with the count. Per-chest breakdown in `Chests`. | `OpenCollectionChestResponse` |\n| `useCollectibleJoker(collectionID, collectibleID)` | Burn one Joker item to grant a specific Collectible. | `UseCollectibleJokerResponse` |\n| `claimSetReward(collectionID, setID)` | Claim a completed Set's reward. | `ClaimSetRewardResponse` |\n| `claimSetRewardsBatch(sets)` | Claim several completed Sets in one atomic call (deduped by SetID). | `ClaimSetRewardsBatchResponse` (`BatchItemResult<ClaimSetRewardResponse>[]`) |\n| `claimGrandPrize(collectionID)` | Claim the Grand Prize once the whole Collection is completed. | `ClaimGrandPrizeResponse` |\n| `sendTradeOffer(collectionID, collectibleID, collectibleIsSpecial, receiverUserID, requestedCollectibleID?, requestedCollectibleIsSpecial?)` | Offer one of your Collectibles to another player, optionally requesting a specific one back. | `SendTradeOfferResponse` |\n| `cancelTradeOffer(offerID)` | Cancel a trade offer you sent. | `CancelTradeOfferResponse` |\n| `acceptTradeOffer(offerID)` | Accept an incoming trade offer (transfers both sides). | `AcceptTradeOfferResponse` |\n| `declineTradeOffer(offerID)` | Decline an incoming trade offer. | `DeclineTradeOfferResponse` |\n| `getMyTradeOffers(collectionID)` | List trade offers you've sent for a collection. | `GetTradeOffersResponse` (`{ Offers: CollectionTradeOfferDocument[] }`) |\n| `getIncomingTradeOffers(collectionID)` | List trade offers sent to you for a collection. | `GetTradeOffersResponse` |\n\nOn success, resource-affecting methods (`openPack`, `openCollectionChest`,\n`useCollectibleJoker`, `claimSetReward`, `claimSetRewardsBatch`,\n`claimGrandPrize`) mirror `data.Resources` (a `ResourceOperation`) into the\ncached currency/item balances — read updated balances straight from\n`client.data.user`. **Trade-offer methods do not touch resource balances or\nthe `Collection` cache slice** — they're domain-only actions that surface\npurely through their event; refetch `getUserState()` / `getMyTradeOffers()` /\n`getIncomingTradeOffers()` to see the effect of a trade.\n\n## Reading state and reacting to changes\n\n```ts\n// Cached after getUserState():\nconst state = client.data.user.state?.Collection;\nstate?.CollectionCurrencyBalance;\nstate?.OwnedCollectibles; // { collectibleID: count }\nstate?.ClaimedSetRewards; // string[]\n\n// Cached after getDefinitions():\nimport type { CollectionDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `collection:definitionsLoaded` → `CollectionDefinitions`\n- `collection:userStateLoaded` → `UserCollectionState`\n- `collection:packOpened` → `OpenPackResponse`\n- `collection:chestOpened` → `OpenCollectionChestResponse`\n- `collection:jokerUsed` → `UseCollectibleJokerResponse`\n- `collection:setRewardClaimed` → `ClaimSetRewardResponse`\n- `collection:setRewardsClaimedBatch` → `ClaimSetRewardsBatchResponse`\n- `collection:grandPrizeClaimed` → `ClaimGrandPrizeResponse`\n- `collection:tradeOfferSent` → `SendTradeOfferResponse`\n- `collection:tradeOfferCancelled` → `CancelTradeOfferResponse`\n- `collection:tradeOfferAccepted` → `AcceptTradeOfferResponse`\n- `collection:tradeOfferDeclined` → `DeclineTradeOfferResponse`\n- `collection:myTradeOffersLoaded` → `GetTradeOffersResponse`\n- `collection:incomingTradeOffersLoaded` → `GetTradeOffersResponse`\n\nThe coarse `user:collectionUpdated` (+ umbrella `user:anyUpdated`) fires only\nfrom `getUserState()` (it's emitted by `applyCollection`, the whole-state\ncache write) — it does **not** fire from pack/chest/joker/claim calls, since\nthose patch resource balances rather than the `Collection` state slice\ndirectly. Re-`getUserState()` after those calls (or after a trade) if you need\nthe cached collection progress to reflect the change.\n\n```ts\nconst off = client.on(\"collection:packOpened\", (r) => {\n for (const c of r.GrantedCollectibles ?? []) console.log(c.CollectibleID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the album and open a pack\n\n```ts\nawait client.collection.getDefinitions();\nawait client.collection.getUserState();\n\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\nconst owned = client.data.user.state?.Collection?.OwnedCollectibles ?? {};\n\nconst pack = await client.collection.openPack(\"main-collection\", \"starter\");\nif (!pack.ok) return showError(pack.error); // e.g. insufficient currency/items\nfor (const c of pack.data.GrantedCollectibles ?? []) {\n // new sticker; check pack.data.DuplicateCollectibles for ones already owned\n}\nif (pack.data.CollectionJustCompleted) showGrandPrizeAvailable();\nfor (const setID of pack.data.NewlyCompletedSetIDs ?? [])\n showSetComplete(setID);\n\n// Balances (pack Cost debited, CollectionCurrencyEarned credited from\n// duplicate conversion) are already reflected here:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\n```\n\n### Spend Collection Currency on a pity chest, then a Joker\n\n```ts\nconst chest = await client.collection.openCollectionChest(\n \"main-collection\",\n \"silver-chest\",\n);\nif (!chest.ok) return showError(chest.error);\n// chest.data.NewCollectionCurrencyBalance reflects the debit; TriggeredPity\n// lists any pity rule(s) that fired on this open.\n\n// Jokers are a regular item (CollectibleJokerItemID in Definitions) burned\n// to grant one specific missing Collectible. Special versions can't be\n// targeted this way — pass collectibleIsSpecial via a Special-only Collectible\n// and it's rejected: \"CollectibleJoker cannot be used for Special Collectibles.\"\nconst joker = await client.collection.useCollectibleJoker(\n \"main-collection\",\n \"card-042\",\n);\nif (!joker.ok) return showError(joker.error); // e.g. \"already owned\", no joker item\nif (joker.data.CollectionJustCompleted) showGrandPrizeAvailable();\n```\n\n### Claim set rewards, then the Grand Prize\n\n```ts\nconst setClaim = await client.collection.claimSetReward(\n \"main-collection\",\n \"set-forest\",\n);\nif (!setClaim.ok) return showError(setClaim.error); // e.g. \"set not completed\", \"already claimed\"\n\nif (client.data.user.state?.Collection?.IsCollectionCompleted) {\n const grand = await client.collection.claimGrandPrize(\"main-collection\");\n if (!grand.ok) return showError(grand.error); // e.g. \"already claimed\"\n}\n```\n\n### Batch-claim several completed sets\n\n```ts\nconst res = await client.collection.claimSetRewardsBatch([\n { CollectionID: \"main-collection\", SetID: \"set-forest\" },\n { CollectionID: \"main-collection\", SetID: \"set-ocean\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) markClaimed(item.Id);\n else showItemError(item.Id, item.Error); // e.g. that set wasn't complete\n}\n```\n\nBatch results are **partial-aware**: `res.ok` says the call ran; each\nelement's `Success`/`Error` says whether that specific set's reward applied.\nOnly the **first** successful item's `Resources` is applied to the cache by\nthe SDK (a `resourcesApplied` guard short-circuits after the first) — if you\nneed every claimed set's grant reflected precisely, re-fetch balances (e.g.\n`client.user`/inventory refresh) after a multi-set batch rather than trusting\nthe cache to have summed them all.\n\n### Trade Collectibles peer-to-peer\n\n```ts\n// Offer my duplicate for a specific card back. Receiver must already be a friend\n// (see social-system) — the server rejects otherwise.\nconst sent = await client.collection.sendTradeOffer(\n \"main-collection\",\n \"card-011\", // CollectibleID I'm giving\n false, // not a Special version\n \"u2\", // receiver\n \"card-042\", // requested back (optional — omit for an open/gift offer)\n);\nif (!sent.ok) return showError(sent.error); // e.g. daily trade limit reached, don't own it\n\n// Receiver's side:\nconst incoming =\n await client.collection.getIncomingTradeOffers(\"main-collection\");\nfor (const offer of incoming.data?.Offers ?? []) {\n // offer.Status === \"Pending\" -> show Accept/Decline\n}\nconst accept = await client.collection.acceptTradeOffer(offer.OfferID);\nif (!accept.ok) return showError(accept.error); // e.g. offer expired, requested card no longer owned\naccept.data.ReceivedCollectibleID; // what I got\naccept.data.SentCollectibleID; // what I gave up\n\n// Sender can cancel while still Pending:\nawait client.collection.cancelTradeOffer(sent.data.OfferID);\n```\n\nRules enforced server-side, not client-side — surface the `error` string, don't\npre-validate:\n\n- **Receiver must be a friend.** `sendTradeOffer` rejects with \"Receiver must be\n in your friends list\" otherwise (see [social-system](../social-system/SKILL.md)\n to add them first).\n- **You need a spare copy to offer or request one back.** A normal Collectible\n needs `OwnedCollectibles[id] >= 2` to be offered or requested (one copy stays\n with you); a Special needs only `>= 1` (it moves entirely, no copy kept\n behind). Rejections read \"You need a duplicate (count >= 2) to trade this\n Collectible.\" / \"You don't own this Special Collectible.\"\n- **The receiver's inbox caps at 10 pending offers**; sending past that fails\n with \"Receiver has too many pending trade offers.\"\n- **Offers expire after 7 days** (168h) from creation — `ExpiresAtUtc` on the\n response and on `CollectionTradeOfferDocument`; `acceptTradeOffer` past that\n point fails with \"Offer has expired.\" Nothing ever flips the stored `Status`\n to `\"Expired\"` server-side, though — `getIncomingTradeOffers` just filters\n lapsed offers out of the list, while `getMyTradeOffers` keeps returning them\n as `Status: \"Pending\"` with a stale `ExpiresAtUtc`. Compare `ExpiresAtUtc`\n to now yourself when rendering your own sent-offers list.\n- Special-version Collectibles can normally only be traded during a\n `SpecialTradeEventDefinition` window (`AllowedSpecialCollectibleIDs`,\n `SpecialTradeEventDailyTradeLimit`) — offering **or being asked for** a\n Special outside that window is rejected server-side on both `sendTradeOffer`\n and `acceptTradeOffer`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key —\n two separate calls are two real operations. A double-clicked \"Open Pack\"\n can open (and charge) twice. Disable the control while a call is in\n flight; the 600ms default throttle window rejects same-endpoint spam with\n `reason: \"throttled\"` but isn't a substitute for disabling the button.\n- **`user:collectionUpdated` is a state-replace signal, not a delta signal.**\n It only fires from `getUserState()`. Don't wire \"refresh the album UI\" to\n it and expect pack/chest/claim calls to trigger it — listen to the\n specific action events (`collection:packOpened`, etc.) instead, or\n re-`getUserState()` after mutating actions if you need the state slice\n itself refreshed.\n- **Trade offers never touch resources or the `Collection` cache slice.**\n There's no automatic balance/inventory update from send/cancel/accept/\n decline — re-fetch `getUserState()` (and re-list offers) to see the\n post-trade picture.\n- **`claimSetRewardsBatch` only applies the first successful item's\n `Resources` to the cache.** If the batch claims multiple sets, don't assume\n the cached currency/item balances reflect all of them — verify against a\n fresh state fetch if the UI shows exact totals.\n- **`UserCollectionState` is loosely typed (passthrough over `{}`).** Unlike\n `CollectionDefinitions` (strictly typed), the per-player state interface is\n a best-effort shape — treat documented fields as likely-present, not\n guaranteed, and code defensively.\n- **Duplicates aren't wasted — they convert to Collection Currency** per\n `DuplicateConversions` (rate keyed by rarity), which is what funds\n `openCollectionChest`. `OpenPackResponse.DuplicateCollectibles` lists which\n pulls were duplicates and `CollectionCurrencyEarned` is the resulting\n credit for that pack.\n- **A season-linked collection can wipe out from under you.** If a\n `CollectionDefinition` has `SeasonChainID` set, the backend resets the\n player's entire `Collection` state (owned Collectibles, currency, claimed\n sets, pity, everything) the moment the linked season rolls over — lazily, on\n the next call that touches Collection. There's no client-side warning event\n for this; just always render from a fresh `getUserState()` rather than\n assuming yesterday's cache is still valid across a session boundary.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the pack/chest reward-slot and pity-rule shape, the trade-offer\ndocument lifecycle, and the joker/duplicate-conversion mechanics.\n",
|
|
4
|
+
"content": "---\nname: collection-system\ndescription: >-\n Build a collection / sticker-album / TCG system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.collection (CollectionService):\n open collectible packs and pity-driven collection chests, spend \"joker\"\n wildcards to fill a specific slot, claim set-completion rewards (single +\n batch) and the collection Grand Prize, and run peer-to-peer collectible\n trading (send/cancel/accept/decline trade offers, list my/incoming offers).\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a sticker album, TCG-style\n collection/set-completion screen, pack-opening UI, duplicate/pity systems,\n or player-to-player item trading — or otherwise touches client.collection,\n CollectionService, CollectionDefinitions, UserCollectionState, or trade\n offers — even if they don't name the module explicitly.\n---\n\n# Collection system (iDosGames TS SDK)\n\nThe Collection module is a \"sticker album\": a title defines one or more\n**Collections**, each made of thematic **Sets** (\"pages\"), each Set made of\n**Collectibles** (\"stickers\", each optionally with a rarer Special version).\nPlayers fill the album by opening **Packs** (lootboxes) and **Collection\nChests** (pity-driven, bought with Collection Currency earned from\nduplicates), can burn a **Joker** wildcard to fill one specific missing\nCollectible, claim a reward when a Set is completed, and claim a **Grand\nPrize** when the whole Collection is completed. A separate **trading**\nsub-system lets players swap Collectibles peer-to-peer.\n\nEverything is **server-authoritative**, same contract as the rest of the SDK:\ncall a method, check `result.ok`, render from the mirrored cache. This skill\nis for **using** the production `CollectionService`, not porting or extending\nit — a rejection is the backend enforcing a rule, surface the error rather\nthan reproducing the check client-side.\n\nThis module frequently sits next to [item-system](../item-system/SKILL.md) or\ncharacter loadouts — Collectibles are a separate currency-and-progress track\nfrom `client.item`/`client.character`, not items themselves, though a title\nmay reward items via `SetCompletionReward` / `GrandPrize`.\n\n## The two data shapes\n\n1. **Definitions** (config) — the title's catalog: `Collections` (each with\n `Sets`, each with `Collectibles`), `PackTypes` (lootbox-style openable\n packs), `CollectionChests` (pity-buy chests priced in Collection\n Currency), `DuplicateConversions` (duplicate → currency rate by rarity),\n `DailyTradeLimit`, the joker's `CollectibleJokerCatalogID` /\n `CollectibleJokerItemID`, and `SpecialTradeEvents` (time windows that\n unlock Special-collectible trading). Fetched with `getDefinitions()`.\n2. **User state** (state, per player) — `CollectionCurrencyBalance`,\n `OwnedCollectibles` / `OwnedSpecialCollectibles` (id → count),\n `ClaimedSetRewards`, `IsCollectionCompleted`, `GrandPrizeClaimed`,\n `DailyTradesSent` (+ reset date), `PendingTradeOfferIDs`, and pity\n `PityCounters`. Fetched with `getUserState()`. **This state object is\n stored wholesale in the cache and typed leniently (`Record`-style\n passthrough)** — read fields defensively (`?.`), don't assume every field\n is always present.\n\nFor the full field-by-field shape, formulas for duplicate conversion, and the\ntrade-offer document shape, read\n[references/data-model.md](references/data-model.md).\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 collection = client.collection; // the CollectionService\n```\n\nEvery method requires an authenticated session; without one they return\n`{ ok: false, reason: \"unauthorized\" }` — none of them throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: `{ ok: true, data }` or\n`{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is one\nof `\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (same\nendpoint fired again inside the 600ms default window), `\"connection\"`\n(transient — offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` has the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's collection catalog (config). | `CollectionDefinitions` |\n| `getUserState()` | Load this player's collection progress (state). | `UserCollectionState` |\n| `openPack(collectionID, packTypeID, count?, options?)` | Open `count` packs (default 1) in one atomic call; cost scales with the count. `count` is clamped server-side to the pack type's `MaxOpenCount` → module `Settings.MaxPackOpenCount` → platform default (100). Read `OpenedCount` for what actually happened and `Packs` for the per-pack breakdown. | `OpenPackResponse` |\n| `openCollectionChest(collectionID, collectionChestID, count?)` | Open `count` pity chests (default 1) in one atomic call; cost scales with the count. Per-chest breakdown in `Chests`. | `OpenCollectionChestResponse` |\n| `useCollectibleJoker(collectionID, collectibleID)` | Burn one Joker item to grant a specific Collectible. | `UseCollectibleJokerResponse` |\n| `claimSetReward(collectionID, setID)` | Claim a completed Set's reward. | `ClaimSetRewardResponse` |\n| `claimSetRewardsBatch(sets)` | Claim several completed Sets in one atomic call (deduped by SetID). | `ClaimSetRewardsBatchResponse` (`BatchItemResult<ClaimSetRewardResponse>[]`) |\n| `claimGrandPrize(collectionID)` | Claim the Grand Prize once the whole Collection is completed. | `ClaimGrandPrizeResponse` |\n| `sendTradeOffer(collectionID, collectibleID, collectibleIsSpecial, receiverUserID, requestedCollectibleID?, requestedCollectibleIsSpecial?)` | Offer one of your Collectibles to another player, optionally requesting a specific one back. | `SendTradeOfferResponse` |\n| `cancelTradeOffer(offerID)` | Cancel a trade offer you sent. | `CancelTradeOfferResponse` |\n| `acceptTradeOffer(offerID)` | Accept an incoming trade offer (transfers both sides). | `AcceptTradeOfferResponse` |\n| `declineTradeOffer(offerID)` | Decline an incoming trade offer. | `DeclineTradeOfferResponse` |\n| `getMyTradeOffers(collectionID)` | List trade offers you've sent for a collection. | `GetTradeOffersResponse` (`{ Offers: CollectionTradeOfferDocument[] }`) |\n| `getIncomingTradeOffers(collectionID)` | List trade offers sent to you for a collection. | `GetTradeOffersResponse` |\n\nOn success, resource-affecting methods (`openPack`, `openCollectionChest`,\n`useCollectibleJoker`, `claimSetReward`, `claimSetRewardsBatch`,\n`claimGrandPrize`) mirror `data.Resources` (a `ResourceOperation`) into the\ncached currency/item balances — read updated balances straight from\n`client.data.user`. **Trade-offer methods do not touch resource balances or\nthe `Collection` cache slice** — they're domain-only actions that surface\npurely through their event; refetch `getUserState()` / `getMyTradeOffers()` /\n`getIncomingTradeOffers()` to see the effect of a trade.\n\n## Reading state and reacting to changes\n\n```ts\n// Cached after getUserState():\nconst state = client.data.user.state?.Collection;\nstate?.CollectionCurrencyBalance;\nstate?.OwnedCollectibles; // { collectibleID: count }\nstate?.ClaimedSetRewards; // string[]\n\n// Cached after getDefinitions():\nimport type { CollectionDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `collection:definitionsLoaded` → `CollectionDefinitions`\n- `collection:userStateLoaded` → `UserCollectionState`\n- `collection:packOpened` → `OpenPackResponse`\n- `collection:chestOpened` → `OpenCollectionChestResponse`\n- `collection:jokerUsed` → `UseCollectibleJokerResponse`\n- `collection:setRewardClaimed` → `ClaimSetRewardResponse`\n- `collection:setRewardsClaimedBatch` → `ClaimSetRewardsBatchResponse`\n- `collection:grandPrizeClaimed` → `ClaimGrandPrizeResponse`\n- `collection:tradeOfferSent` → `SendTradeOfferResponse`\n- `collection:tradeOfferCancelled` → `CancelTradeOfferResponse`\n- `collection:tradeOfferAccepted` → `AcceptTradeOfferResponse`\n- `collection:tradeOfferDeclined` → `DeclineTradeOfferResponse`\n- `collection:myTradeOffersLoaded` → `GetTradeOffersResponse`\n- `collection:incomingTradeOffersLoaded` → `GetTradeOffersResponse`\n\nThe coarse `user:collectionUpdated` (+ umbrella `user:anyUpdated`) fires only\nfrom `getUserState()` (it's emitted by `applyCollection`, the whole-state\ncache write) — it does **not** fire from pack/chest/joker/claim calls, since\nthose patch resource balances rather than the `Collection` state slice\ndirectly. Re-`getUserState()` after those calls (or after a trade) if you need\nthe cached collection progress to reflect the change.\n\n```ts\nconst off = client.on(\"collection:packOpened\", (r) => {\n for (const c of r.GrantedCollectibles ?? []) console.log(c.CollectibleID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the album and open a pack\n\n```ts\nawait client.collection.getDefinitions();\nawait client.collection.getUserState();\n\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\nconst owned = client.data.user.state?.Collection?.OwnedCollectibles ?? {};\n\n// `options` picks the way to pay (`selectedOptionID`) and carries a store receipt\n// (`payment`) when the chosen option is paid in a store — see the checkout-system skill.\nconst pack = await client.collection.openPack(\"main-collection\", \"starter\");\nif (!pack.ok) return showError(pack.error); // e.g. insufficient currency/items\nfor (const c of pack.data.GrantedCollectibles ?? []) {\n // new sticker; check pack.data.DuplicateCollectibles for ones already owned\n}\nif (pack.data.CollectionJustCompleted) showGrandPrizeAvailable();\nfor (const setID of pack.data.NewlyCompletedSetIDs ?? [])\n showSetComplete(setID);\n\n// Balances (pack Cost debited, CollectionCurrencyEarned credited from\n// duplicate conversion) are already reflected here:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\n```\n\n### Spend Collection Currency on a pity chest, then a Joker\n\n```ts\nconst chest = await client.collection.openCollectionChest(\n \"main-collection\",\n \"silver-chest\",\n);\nif (!chest.ok) return showError(chest.error);\n// chest.data.NewCollectionCurrencyBalance reflects the debit; TriggeredPity\n// lists any pity rule(s) that fired on this open.\n\n// Jokers are a regular item (CollectibleJokerItemID in Definitions) burned\n// to grant one specific missing Collectible. Special versions can't be\n// targeted this way — pass collectibleIsSpecial via a Special-only Collectible\n// and it's rejected: \"CollectibleJoker cannot be used for Special Collectibles.\"\nconst joker = await client.collection.useCollectibleJoker(\n \"main-collection\",\n \"card-042\",\n);\nif (!joker.ok) return showError(joker.error); // e.g. \"already owned\", no joker item\nif (joker.data.CollectionJustCompleted) showGrandPrizeAvailable();\n```\n\n### Claim set rewards, then the Grand Prize\n\n```ts\nconst setClaim = await client.collection.claimSetReward(\n \"main-collection\",\n \"set-forest\",\n);\nif (!setClaim.ok) return showError(setClaim.error); // e.g. \"set not completed\", \"already claimed\"\n\nif (client.data.user.state?.Collection?.IsCollectionCompleted) {\n const grand = await client.collection.claimGrandPrize(\"main-collection\");\n if (!grand.ok) return showError(grand.error); // e.g. \"already claimed\"\n}\n```\n\n### Batch-claim several completed sets\n\n```ts\nconst res = await client.collection.claimSetRewardsBatch([\n { CollectionID: \"main-collection\", SetID: \"set-forest\" },\n { CollectionID: \"main-collection\", SetID: \"set-ocean\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) markClaimed(item.Id);\n else showItemError(item.Id, item.Error); // e.g. that set wasn't complete\n}\n```\n\nBatch results are **partial-aware**: `res.ok` says the call ran; each\nelement's `Success`/`Error` says whether that specific set's reward applied.\nOnly the **first** successful item's `Resources` is applied to the cache by\nthe SDK (a `resourcesApplied` guard short-circuits after the first) — if you\nneed every claimed set's grant reflected precisely, re-fetch balances (e.g.\n`client.user`/inventory refresh) after a multi-set batch rather than trusting\nthe cache to have summed them all.\n\n### Trade Collectibles peer-to-peer\n\n```ts\n// Offer my duplicate for a specific card back. Receiver must already be a friend\n// (see social-system) — the server rejects otherwise.\nconst sent = await client.collection.sendTradeOffer(\n \"main-collection\",\n \"card-011\", // CollectibleID I'm giving\n false, // not a Special version\n \"u2\", // receiver\n \"card-042\", // requested back (optional — omit for an open/gift offer)\n);\nif (!sent.ok) return showError(sent.error); // e.g. daily trade limit reached, don't own it\n\n// Receiver's side:\nconst incoming =\n await client.collection.getIncomingTradeOffers(\"main-collection\");\nfor (const offer of incoming.data?.Offers ?? []) {\n // offer.Status === \"Pending\" -> show Accept/Decline\n}\nconst accept = await client.collection.acceptTradeOffer(offer.OfferID);\nif (!accept.ok) return showError(accept.error); // e.g. offer expired, requested card no longer owned\naccept.data.ReceivedCollectibleID; // what I got\naccept.data.SentCollectibleID; // what I gave up\n\n// Sender can cancel while still Pending:\nawait client.collection.cancelTradeOffer(sent.data.OfferID);\n```\n\nRules enforced server-side, not client-side — surface the `error` string, don't\npre-validate:\n\n- **Receiver must be a friend.** `sendTradeOffer` rejects with \"Receiver must be\n in your friends list\" otherwise (see [social-system](../social-system/SKILL.md)\n to add them first).\n- **You need a spare copy to offer or request one back.** A normal Collectible\n needs `OwnedCollectibles[id] >= 2` to be offered or requested (one copy stays\n with you); a Special needs only `>= 1` (it moves entirely, no copy kept\n behind). Rejections read \"You need a duplicate (count >= 2) to trade this\n Collectible.\" / \"You don't own this Special Collectible.\"\n- **The receiver's inbox caps at 10 pending offers**; sending past that fails\n with \"Receiver has too many pending trade offers.\"\n- **Offers expire after 7 days** (168h) from creation — `ExpiresAtUtc` on the\n response and on `CollectionTradeOfferDocument`; `acceptTradeOffer` past that\n point fails with \"Offer has expired.\" Nothing ever flips the stored `Status`\n to `\"Expired\"` server-side, though — `getIncomingTradeOffers` just filters\n lapsed offers out of the list, while `getMyTradeOffers` keeps returning them\n as `Status: \"Pending\"` with a stale `ExpiresAtUtc`. Compare `ExpiresAtUtc`\n to now yourself when rendering your own sent-offers list.\n- Special-version Collectibles can normally only be traded during a\n `SpecialTradeEventDefinition` window (`AllowedSpecialCollectibleIDs`,\n `SpecialTradeEventDailyTradeLimit`) — offering **or being asked for** a\n Special outside that window is rejected server-side on both `sendTradeOffer`\n and `acceptTradeOffer`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key —\n two separate calls are two real operations. A double-clicked \"Open Pack\"\n can open (and charge) twice. Disable the control while a call is in\n flight; the 600ms default throttle window rejects same-endpoint spam with\n `reason: \"throttled\"` but isn't a substitute for disabling the button.\n- **`user:collectionUpdated` is a state-replace signal, not a delta signal.**\n It only fires from `getUserState()`. Don't wire \"refresh the album UI\" to\n it and expect pack/chest/claim calls to trigger it — listen to the\n specific action events (`collection:packOpened`, etc.) instead, or\n re-`getUserState()` after mutating actions if you need the state slice\n itself refreshed.\n- **Trade offers never touch resources or the `Collection` cache slice.**\n There's no automatic balance/inventory update from send/cancel/accept/\n decline — re-fetch `getUserState()` (and re-list offers) to see the\n post-trade picture.\n- **`claimSetRewardsBatch` only applies the first successful item's\n `Resources` to the cache.** If the batch claims multiple sets, don't assume\n the cached currency/item balances reflect all of them — verify against a\n fresh state fetch if the UI shows exact totals.\n- **`UserCollectionState` is loosely typed (passthrough over `{}`).** Unlike\n `CollectionDefinitions` (strictly typed), the per-player state interface is\n a best-effort shape — treat documented fields as likely-present, not\n guaranteed, and code defensively.\n- **Duplicates aren't wasted — they convert to Collection Currency** per\n `DuplicateConversions` (rate keyed by rarity), which is what funds\n `openCollectionChest`. `OpenPackResponse.DuplicateCollectibles` lists which\n pulls were duplicates and `CollectionCurrencyEarned` is the resulting\n credit for that pack.\n- **A season-linked collection can wipe out from under you.** If a\n `CollectionDefinition` has `SeasonChainID` set, the backend resets the\n player's entire `Collection` state (owned Collectibles, currency, claimed\n sets, pity, everything) the moment the linked season rolls over — lazily, on\n the next call that touches Collection. There's no client-side warning event\n for this; just always render from a fresh `getUserState()` rather than\n assuming yesterday's cache is still valid across a session boundary.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the pack/chest reward-slot and pity-rule shape, the trade-offer\ndocument lifecycle, and the joker/duplicate-conversion mechanics.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Collection data model — reference\n\nFull shape of the config (`CollectionDefinitions`), player state\n(`UserCollectionState`), the pack/chest reward mechanics, and the trade-offer\nlifecycle. Config types are **strictly typed in the SDK** — `CollectionDefinitions`\nand every nested block are exported from `@idosgames/core`. Every schema keeps\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CollectionDefinitions](#config-collectiondefinitions)\n- [CollectionDefinition / Sets / Collectibles](#collectiondefinition--sets--collectibles)\n- [PackTypes and CollectionChests (reward slots + pity)](#packtypes-and-collectionchests)\n- [Duplicate conversion](#duplicate-conversion)\n- [SpecialTradeEvents](#specialtradeevents)\n- [Player state: UserCollectionState](#player-state-usercollectionstate)\n- [Season-linked wipe](#season-linked-wipe)\n- [Trade offers](#trade-offers)\n- [Responses](#responses)\n\n---\n\n## Config: CollectionDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CollectionDefinitions>(\"Collection\")`.\n\n```ts\ninterface CollectionDefinitions {\n Collections?: Record<string, CollectionDefinition> | null; // key = CollectionID\n PackTypes?: Record<string, CollectionPackTypeDefinition> | null; // key = PackTypeID\n CollectionChests?: CollectionChestDefinition[] | null;\n DuplicateConversions?: DuplicateCollectionCurrencyConversion[] | null;\n DailyTradeLimit?: number | null;\n CollectibleJokerCatalogID?: string | null;\n CollectibleJokerItemID?: string | null;\n SpecialTradeEvents?: SpecialTradeEventDefinition[] | null;\n}\n```\n\n`DailyTradeLimit` bounds `sendTradeOffer` calls per calendar day (tracked by\n`UserCollectionState.DailyTradesSent` / `DailyTradesResetDate`, reset at UTC\nmidnight); backend default is **5/day** if the title doesn't set it.\n`CollectibleJokerItemID` (optionally scoped by `CollectibleJokerCatalogID`) is\nthe item burned by `useCollectibleJoker` — grant this item to players through\nthe Item/Store/Lootbox modules; the Collection module only consumes it. It's a\nnormal `InventoryV2.Items` item and does not burn on a season wipe, so players\ncan bank Jokers across seasons.\n\n---\n\n## CollectionDefinition / Sets / Collectibles\n\nThree-level hierarchy: Collection → Set → Collectible.\n\n```ts\ninterface CollectionDefinition {\n CollectionID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n SeasonChainID?: string; // links this collection to a season chain, if any\n Sets?: CollectionSetDefinition[];\n GrandPrize?: ResourceGrant; // claimed once via claimGrandPrize()\n}\n\ninterface CollectionSetDefinition {\n SetID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n SortOrder?: number;\n Collectibles?: CollectibleDefinition[];\n SetCompletionReward?: ResourceGrant; // claimed once via claimSetReward()\n}\n\ninterface CollectibleDefinition {\n CollectibleID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Rarity?: number;\n HasSpecialVersion?: boolean; // a rarer \"Special\" variant exists for this id\n SortOrder?: number;\n}\n```\n\nA Collection is \"completed\" (`IsCollectionCompleted`) once every Set inside it\nis completed; a Set is completed once every listed Collectible has been\nobtained at least once (`OwnedCollectibles[id] >= 1`). Owning duplicates past\n1 does not grant anything further directly — see\n[Duplicate conversion](#duplicate-conversion).\n\n---\n\n## PackTypes and CollectionChests\n\nBoth are openable reward containers priced differently: Packs cost the shared\n`ResourceConsume` type (currency/items/event tokens); Chests are priced purely\nin `CollectionCurrencyCost` (the module's own soft currency, earned from\nduplicates).\n\n```ts\ninterface CollectionPackTypeDefinition {\n PackTypeID?: string;\n Cost?: ResourceConsume; // charged by openPack(); required non-empty or the open is rejected\n BonusRewardSlots?: LootboxRewardSlot[]; // extra non-collectible rewards\n PityRules?: LootboxPityRule[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectibleCount?: number; // how many Collectibles this pack grants (backend default 3)\n GuaranteedMinRarity?: number; // backend default 1\n GuaranteeMaxRarity?: boolean; // backend default false\n RarityWeights?: Record<string, number>; // rarity id (as string \"1\"..\"5\") -> drop weight\n ColorTier?: number; // 1=Green,2=Blue,3=Orange,4=Purple; backend default 1\n}\n\ninterface CollectionChestDefinition {\n CollectionChestID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectionCurrencyCost?: number;\n MinCollectibleCount?: number; // backend default 1\n MaxCollectibleCount?: number; // backend default 2\n GuaranteedMinRarity?: number; // backend default 2\n BonusRewardSlots?: LootboxRewardSlot[];\n PityRules?: LootboxPityRule[];\n Tier?: number; // 1=Bronze,2=Silver,3=Gold; backend default 1\n}\n```\n\n`BonusRewardSlots` / `PityRules` reuse the shared reward-slot primitives from\n`_shared/RewardSlotModels.ts` (the same ones the Lootbox module uses — see\n[lootbox-system](../../lootbox-system/SKILL.md) if you need the full\nslot/pool/pity mechanics):\n\n```ts\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only; no Consume side\n Weight?: number;\n AmountRange?: { Min?: number; Max?: number };\n}\ninterface LootboxRewardSlot {\n SlotID?: string;\n MinRolls?: number; // independent rolls to make over Pool\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxPityRule {\n RuleID?: string;\n Threshold?: number; // every Nth open without a qualifying pull, force one\n Pool?: LootboxRewardRoll[];\n}\n```\n\n**Pack roll (`OpenPack`, per Collectible slot, `Collection.cs`\n`RollCollectiblesForPack`):** slot 0 gets the pack's guarantee — if\n`GuaranteeMaxRarity` is true it forces rarity 5, otherwise it weighted-picks\nfrom `RarityWeights` with a floor of `GuaranteedMinRarity`; every other slot\nweighted-picks with a floor of rarity 1. The weighted pick\n(`PickRarityWeighted`) filters `RarityWeights` entries to `rarity >= floor`,\nsums their weights, and rolls uniformly in `[0, total)` via `SecureRandom`; if\nno candidate matches the exact rarity it widens to `>= targetRarity`, then\nfalls back to the full Collectible pool. A picked Collectible that already has\n`HasSpecialVersion: true` additionally has a **flat 5% chance** to be granted\nas its Special version instead of the normal one (`SecureRandom.Next(0,100) <\n5`) — this 5% is hardcoded, not title-configurable.\n\n**Chest roll (`OpenCollectionChest`, `RollCollectiblesForCollectionChest`):**\npicks a random Collectible count uniformly in\n`[MinCollectibleCount, MaxCollectibleCount]`, then for each pick filters the\npool to `Rarity >= GuaranteedMinRarity` (falling back to the full pool if that\nfilter is empty) and picks uniformly at random. Chests never roll Special\nversions.\n\n**Duplicate detection happens per-roll, in-order, within the same open**: the\n\"already owned\" check adds in any Collectibles already granted earlier in the\n_same_ pack/chest before checking the next slot, so pulling the same\nCollectible twice in one 5-slot pack correctly flags the second as a\nduplicate even though neither has hit the database yet.\n\nPity progress is tracked per rule in\n`UserCollectionState.PityCounters: Record<string, UserLootboxPityCounter>`,\nkeyed by **`\"{PackTypeID or CollectionChestID}:{RuleID}\"`** (literal colon\njoin; `CollectionPityHelpers.CounterKey`) — not by `RuleID` alone, so the same\n`RuleID` reused across two pack types tracks independently. The counter type\nis shared with the Lootbox module. Math per open (count is always 1 for\nCollection, unlike Lootbox's multi-open): `totalSteps = counter + 1`,\n`triggers = totalSteps / Threshold` (0 or 1), `newCounter = totalSteps %\nThreshold` — i.e. classic hard-pity, resets to 0 exactly on the open that\nhits the threshold. `OpenPackResponse` / `OpenCollectionChestResponse` both\ncarry `TriggeredPity: unknown[]` — the response signals _that_ pity fired\n(with `RuleID`, always `BoxIndex: 0` for Collection) but doesn't strictly\ntype the payload shape; treat it as informational (e.g. a \"pity!\" toast)\nrather than something to branch business logic on.\n\n---\n\n## Duplicate conversion\n\n```ts\ninterface DuplicateCollectionCurrencyConversion {\n Rarity?: number;\n CollectionCurrencyGranted?: number;\n}\n```\n\nWhen a pack/chest pull is a Collectible the player already owns, instead of\nstacking uselessly it auto-converts into `CollectionCurrencyGranted` (looked\nup by the pulled Collectible's `Rarity` in this list) — that's the\n`CollectionCurrencyEarned` you see on `OpenPackResponse` /\n`OpenCollectionChestResponse`, and it's what funds `openCollectionChest`. This\nis why chests exist: a way to spend \"wasted\" duplicate pulls on guaranteed\nprogress instead.\n\n**Fallback when no rule matches the rarity:** `GetCollectionCurrencyForDuplicate`\nfalls back to `rarity` itself (i.e. a rarity-3 duplicate grants 3 Collection\nCurrency) if `DuplicateConversions` has no entry for that rarity — so an\nincomplete conversion table doesn't silently grant 0, but also won't match\nwhatever curve you intended. Configure every rarity 1-5 explicitly rather than\nrelying on the fallback.\n\nA duplicate normally caps ownership at effectively 1 (the doc comment on\n`UserCollectionState.OwnedCollectibles` calls `>= 2` a rare/transient state —\nconversion is meant to be immediate) but the code path that increments it is\nplain `Dictionary` arithmetic in memory before the Mongo patch, so treat\n`OwnedCollectibles[id]` as \"0, 1, or rarely-briefly more,\" not a strict\nboolean.\n\n---\n\n## SpecialTradeEvents\n\n```ts\ninterface SpecialTradeEventDefinition {\n SpecialTradeEventID?: string;\n StartUtc?: string;\n EndUtc?: string;\n AllowedSpecialCollectibleIDs?: string[];\n SpecialTradeEventDailyTradeLimit?: number;\n}\n```\n\nSpecial-version Collectibles (`HasSpecialVersion: true` on the base\nCollectible, traded with `collectibleIsSpecial: true`) can only move via\n`sendTradeOffer` while an active event's window covers `now` **and** lists\nthat Collectible in `AllowedSpecialCollectibleIDs`. Outside any such window,\noffering a Special is rejected server-side. The event also carries its own\ndaily limit distinct from the title-wide `DailyTradeLimit`.\n\n---\n\n## Player state: UserCollectionState\n\nReturned by `getUserState()`; cached at `client.data.user.state?.Collection`.\n**Loosely typed** (`z.object({}).passthrough()` cast to the interface) —\nunlike the config side, this is not field-validated, so treat it as\nbest-effort and read defensively.\n\n```ts\ninterface UserCollectionState {\n CollectionID?: string;\n SeasonVersion?: number;\n CollectionCurrencyBalance?: number;\n TotalCollectionCurrencyEarned?: number;\n OwnedCollectibles?: Record<string, number>; // CollectibleID -> count owned\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[]; // SetIDs already claimed\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n DailyTradesSent?: number;\n DailyTradesResetDate?: string;\n PendingTradeOfferIDs?: string[];\n PityCounters?: Record<string, UserLootboxPityCounter>; // key = \"{PackTypeID|CollectionChestID}:{RuleID}\"\n}\n```\n\n`SeasonVersion` defaults to `0` when the collection isn't season-linked.\n`PityCounters` (like `OwnedCollectibles`/`OwnedSpecialCollectibles`) is a plain\ndictionary that only gains a key the first time that pool triggers — treat a\nmissing key as counter `0`, not an error.\n\n---\n\n## Season-linked wipe\n\nA `CollectionDefinition` may set `SeasonChainID` to bind itself to a season\nchain (`Season` module). Every Collection action re-derives the \"current\"\n`(activeCollectionID, SeasonVersion)` pair on each call\n(`EnsureCollectionWipedIfNeededAsync` in `Collection.cs`):\n\n- If any season chain has a `LinkedCollectionID` whose window is currently\n active (not paused), that collection is the active one, and\n `SeasonVersion = CycleIndex * 1000 + SeasonOrder` of that window.\n- Otherwise, if the title has no season-linked collection, the **first**\n collection in config-declaration order (`Collections.Keys.First()`) is used\n with `SeasonVersion = 0`.\n\nIf the player's stored `UserCollectionState.CollectionID` /\n`SeasonVersion` doesn't match, the **entire** Collection state is wiped and\nreplaced with a fresh zeroed one (new `CollectionID`, `SeasonVersion`, empty\n`OwnedCollectibles`/`OwnedSpecialCollectibles`/`ClaimedSetRewards`/\n`PendingTradeOfferIDs`, zeroed currency, `IsCollectionCompleted`/\n`GrandPrizeClaimed` reset to `false`) — this happens **lazily**, on the very\nnext Collection call the player makes after the season rolls over, not on a\nschedule. There is no dedicated wipe event; the wiped state is simply what\nthe next `getUserState()` (or any other Collection call) returns. Design\naround this: don't assume a cached `Collection` state survives across a\nsession gap without a fresh fetch, and don't build UI that depends on\n`OwnedCollectibles` persisting across a season boundary for a season-linked\ncollection.\n\n---\n\n## Trade offers\n\n```ts\ninterface CollectionTradeOfferDocument {\n OfferID: string;\n TitleID?: string;\n CollectionID?: string;\n SenderUserID?: string;\n SenderPublicData?: UserPublicDataModel; // sender's public profile snapshot\n OfferedCollectibleID?: string;\n OfferedCollectibleIsSpecial?: boolean;\n ReceiverUserID?: string;\n RequestedCollectibleID?: string; // absent = open/gift offer, no ask-back\n RequestedCollectibleIsSpecial?: boolean;\n Status?: \"Pending\" | \"Accepted\" | \"Declined\" | \"Cancelled\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RespondedAtUtc?: string;\n DeclineReason?: string;\n IsSpecialTradeEvent?: boolean;\n SpecialTradeEventID?: string;\n}\n```\n\n**Preconditions checked by `sendTradeOffer` (`Collection.cs` `SendTradeOffer`),\nin order:** `ReceiverUserID` can't equal your own `UserID` (\"Cannot trade with\nyourself\"); the receiver must be in your **Social.Accepted friends list**\n(\"Receiver must be in your friends list\" — see\n[social-system](../../social-system/SKILL.md)); if `CollectibleIsSpecial` a\nmatching active `SpecialTradeEventDefinition` must exist (\"Special\nCollectibles can only be traded during an active SpecialTradeEvent\"); the\neffective daily limit (event-specific limit if trading a Special during its\nevent window, else the title's `DailyTradeLimit`, lazily reset at UTC\nmidnight) must not already be hit (\"Daily trade limit reached (N/day)\"); you\nmust hold enough of the offered Collectible — **`>= 2`** for a normal\nCollectible (you keep one, offer the spare) or **`>= 1`** for a Special (the\nwhole thing moves, no spare kept back); and the receiver's\n`PendingTradeOfferIDs` must have fewer than **10** entries\n(`MaxPendingIncomingOffers`) — \"Receiver has too many pending trade offers.\"\nThe same `>= 2` (normal) / `>= 1` (Special) ownership check re-runs against\nthe **receiver's** balance for `RequestedCollectibleID` at `acceptTradeOffer`\ntime, since their holdings may have changed since the offer was sent.\n\nLifecycle: `sendTradeOffer` creates a document with `Status: \"Pending\"`,\n`CreatedAtUtc: now`, and `ExpiresAtUtc: now + 168h` (7 days —\n`TradeOfferExpirationHours` in `Collection.cs`, not title-configurable). The\nreceiver calls `getIncomingTradeOffers` to see it, then either\n`acceptTradeOffer` (→ `Status: \"Accepted\"`, both Collectibles swap owners; also\nrejected if `ExpiresAtUtc <= now`, \"Offer has expired\") or `declineTradeOffer`\n(→ `Status: \"Declined\"`). The sender can `cancelTradeOffer` any offer still\n`\"Pending\"` (→ `Status: \"Cancelled\"`).\n\n**`\"Expired\"` is a declared `Status` value the backend never actually\nwrites** — there is no sweep job that flips stale offers to `Expired`.\n`getIncomingTradeOffers` filters server-side to `Status == \"Pending\" &&\nExpiresAtUtc > now`, so an expired incoming offer just silently drops out of\nthat list (it doesn't surface with a distinguishable status). `getMyTradeOffers`\n(outgoing) has **no such filter** — it returns everything you've ever sent for\nthat collection (newest 20), so a lapsed offer you sent still reads\n`Status: \"Pending\"` with an `ExpiresAtUtc` in the past; compare `ExpiresAtUtc`\nagainst the current time yourself if you need to grey it out in a \"my offers\"\nlist. None of the four trade actions mutate `client.data.user` directly (no\n`Resources`, no `Collection` cache patch) — re-fetch `getUserState()` / the\noffer lists to observe the effect.\n\n---\n\n## Responses\n\n```ts\ninterface GrantedCollectible {\n CollectibleID: string;\n Rarity?: number;\n IsSpecial?: boolean;\n IsDuplicate?: boolean;\n CollectionCurrencyConverted?: number; // set when IsDuplicate\n}\n\ninterface OpenPackResponse {\n GrantedCollectibles?: GrantedCollectible[]; // full pull list (incl. duplicates)\n DuplicateCollectibles?: GrantedCollectible[]; // subset that were duplicates\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n NewlyCompletedSetIDs?: string[];\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // pack Cost debit (+ BonusRewardSlots grants)\n TriggeredPity?: unknown[];\n}\n\ninterface OpenCollectionChestResponse {\n GrantedCollectibles?: GrantedCollectible[];\n DuplicateCollectibles?: GrantedCollectible[];\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n Resources?: ResourceOperation; // CollectionCurrencyCost debit (+ bonus grants)\n TriggeredPity?: unknown[];\n}\n\ninterface UseCollectibleJokerResponse {\n GrantedCollectibleID?: string;\n NewlyCompletedSetID?: string;\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // Joker item consumed\n}\n\ninterface ClaimSetRewardResponse {\n SetID: string;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimGrandPrizeResponse {\n Resources?: ResourceOperation;\n}\n\ninterface SendTradeOfferResponse {\n OfferID: string;\n ExpiresAtUtc?: string;\n Resources?: ResourceOperation; // usually absent; trading has no inherent cost\n}\n\ninterface AcceptTradeOfferResponse {\n OfferID: string;\n ReceivedCollectibleID?: string;\n ReceivedIsSpecial?: boolean;\n SentCollectibleID?: string;\n SentCollectibleIsSpecial?: boolean;\n Transfer?: unknown; // server-internal transfer record, not strictly typed\n}\n```\n\n`ClaimSetRewardsBatchResponse` is `BatchItemResult<ClaimSetRewardResponse>[]`\n— see the shared `BatchItemResult<T>` shape\n(`_shared/BatchModels.ts`): `{ Id, Success, Error?, Data? }` per item, one\natomic charge across the whole batch.\n"
|
|
8
|
+
"content": "# Collection data model — reference\n\nFull shape of the config (`CollectionDefinitions`), player state\n(`UserCollectionState`), the pack/chest reward mechanics, and the trade-offer\nlifecycle. Config types are **strictly typed in the SDK** — `CollectionDefinitions`\nand every nested block are exported from `@idosgames/core`. Every schema keeps\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CollectionDefinitions](#config-collectiondefinitions)\n- [CollectionDefinition / Sets / Collectibles](#collectiondefinition--sets--collectibles)\n- [PackTypes and CollectionChests (reward slots + pity)](#packtypes-and-collectionchests)\n- [Duplicate conversion](#duplicate-conversion)\n- [SpecialTradeEvents](#specialtradeevents)\n- [Player state: UserCollectionState](#player-state-usercollectionstate)\n- [Season-linked wipe](#season-linked-wipe)\n- [Trade offers](#trade-offers)\n- [Responses](#responses)\n\n---\n\n## Config: CollectionDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CollectionDefinitions>(\"Collection\")`.\n\n```ts\ninterface CollectionDefinitions {\n Collections?: Record<string, CollectionDefinition> | null; // key = CollectionID\n PackTypes?: Record<string, CollectionPackTypeDefinition> | null; // key = PackTypeID\n CollectionChests?: CollectionChestDefinition[] | null;\n DuplicateConversions?: DuplicateCollectionCurrencyConversion[] | null;\n DailyTradeLimit?: number | null;\n CollectibleJokerCatalogID?: string | null;\n CollectibleJokerItemID?: string | null;\n SpecialTradeEvents?: SpecialTradeEventDefinition[] | null;\n}\n```\n\n`DailyTradeLimit` bounds `sendTradeOffer` calls per calendar day (tracked by\n`UserCollectionState.DailyTradesSent` / `DailyTradesResetDate`, reset at UTC\nmidnight); backend default is **5/day** if the title doesn't set it.\n`CollectibleJokerItemID` (optionally scoped by `CollectibleJokerCatalogID`) is\nthe item burned by `useCollectibleJoker` — grant this item to players through\nthe Item/Store/Lootbox modules; the Collection module only consumes it. It's a\nnormal `InventoryV2.Items` item and does not burn on a season wipe, so players\ncan bank Jokers across seasons.\n\n---\n\n## CollectionDefinition / Sets / Collectibles\n\nThree-level hierarchy: Collection → Set → Collectible.\n\n```ts\ninterface CollectionDefinition {\n CollectionID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n SeasonChainID?: string; // links this collection to a season chain, if any\n Sets?: CollectionSetDefinition[];\n GrandPrize?: ResourceGrant; // claimed once via claimGrandPrize()\n}\n\ninterface CollectionSetDefinition {\n SetID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n SortOrder?: number;\n Collectibles?: CollectibleDefinition[];\n SetCompletionReward?: ResourceGrant; // claimed once via claimSetReward()\n}\n\ninterface CollectibleDefinition {\n CollectibleID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Rarity?: number;\n HasSpecialVersion?: boolean; // a rarer \"Special\" variant exists for this id\n SortOrder?: number;\n}\n```\n\nA Collection is \"completed\" (`IsCollectionCompleted`) once every Set inside it\nis completed; a Set is completed once every listed Collectible has been\nobtained at least once (`OwnedCollectibles[id] >= 1`). Owning duplicates past\n1 does not grant anything further directly — see\n[Duplicate conversion](#duplicate-conversion).\n\n---\n\n## PackTypes and CollectionChests\n\nBoth are openable reward containers priced differently: Packs cost the shared\n`ResourceConsume` type (currency/items/event tokens); Chests are priced purely\nin `CollectionCurrencyCost` (the module's own soft currency, earned from\nduplicates).\n\n```ts\ninterface CollectionPackTypeDefinition {\n PackTypeID?: string;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; the selected one is charged by openPack(), required non-empty or the open is rejected\n BonusRewardSlots?: LootboxRewardSlot[]; // extra non-collectible rewards\n PityRules?: LootboxPityRule[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectibleCount?: number; // how many Collectibles this pack grants (backend default 3)\n GuaranteedMinRarity?: number; // backend default 1\n GuaranteeMaxRarity?: boolean; // backend default false\n RarityWeights?: Record<string, number>; // rarity id (as string \"1\"..\"5\") -> drop weight\n ColorTier?: number; // 1=Green,2=Blue,3=Orange,4=Purple; backend default 1\n}\n\ninterface CollectionChestDefinition {\n CollectionChestID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectionCurrencyCost?: number;\n MinCollectibleCount?: number; // backend default 1\n MaxCollectibleCount?: number; // backend default 2\n GuaranteedMinRarity?: number; // backend default 2\n BonusRewardSlots?: LootboxRewardSlot[];\n PityRules?: LootboxPityRule[];\n Tier?: number; // 1=Bronze,2=Silver,3=Gold; backend default 1\n}\n```\n\n`BonusRewardSlots` / `PityRules` reuse the shared reward-slot primitives from\n`_shared/RewardSlotModels.ts` (the same ones the Lootbox module uses — see\n[lootbox-system](../../lootbox-system/SKILL.md) if you need the full\nslot/pool/pity mechanics):\n\n```ts\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only; no Consume side\n Weight?: number;\n AmountRange?: { Min?: number; Max?: number };\n}\ninterface LootboxRewardSlot {\n SlotID?: string;\n MinRolls?: number; // independent rolls to make over Pool\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxPityRule {\n RuleID?: string;\n Threshold?: number; // every Nth open without a qualifying pull, force one\n Pool?: LootboxRewardRoll[];\n}\n```\n\n**Pack roll (`OpenPack`, per Collectible slot, `Collection.cs`\n`RollCollectiblesForPack`):** slot 0 gets the pack's guarantee — if\n`GuaranteeMaxRarity` is true it forces rarity 5, otherwise it weighted-picks\nfrom `RarityWeights` with a floor of `GuaranteedMinRarity`; every other slot\nweighted-picks with a floor of rarity 1. The weighted pick\n(`PickRarityWeighted`) filters `RarityWeights` entries to `rarity >= floor`,\nsums their weights, and rolls uniformly in `[0, total)` via `SecureRandom`; if\nno candidate matches the exact rarity it widens to `>= targetRarity`, then\nfalls back to the full Collectible pool. A picked Collectible that already has\n`HasSpecialVersion: true` additionally has a **flat 5% chance** to be granted\nas its Special version instead of the normal one (`SecureRandom.Next(0,100) <\n5`) — this 5% is hardcoded, not title-configurable.\n\n**Chest roll (`OpenCollectionChest`, `RollCollectiblesForCollectionChest`):**\npicks a random Collectible count uniformly in\n`[MinCollectibleCount, MaxCollectibleCount]`, then for each pick filters the\npool to `Rarity >= GuaranteedMinRarity` (falling back to the full pool if that\nfilter is empty) and picks uniformly at random. Chests never roll Special\nversions.\n\n**Duplicate detection happens per-roll, in-order, within the same open**: the\n\"already owned\" check adds in any Collectibles already granted earlier in the\n_same_ pack/chest before checking the next slot, so pulling the same\nCollectible twice in one 5-slot pack correctly flags the second as a\nduplicate even though neither has hit the database yet.\n\nPity progress is tracked per rule in\n`UserCollectionState.PityCounters: Record<string, UserLootboxPityCounter>`,\nkeyed by **`\"{PackTypeID or CollectionChestID}:{RuleID}\"`** (literal colon\njoin; `CollectionPityHelpers.CounterKey`) — not by `RuleID` alone, so the same\n`RuleID` reused across two pack types tracks independently. The counter type\nis shared with the Lootbox module. Math per open (count is always 1 for\nCollection, unlike Lootbox's multi-open): `totalSteps = counter + 1`,\n`triggers = totalSteps / Threshold` (0 or 1), `newCounter = totalSteps %\nThreshold` — i.e. classic hard-pity, resets to 0 exactly on the open that\nhits the threshold. `OpenPackResponse` / `OpenCollectionChestResponse` both\ncarry `TriggeredPity: unknown[]` — the response signals _that_ pity fired\n(with `RuleID`, always `BoxIndex: 0` for Collection) but doesn't strictly\ntype the payload shape; treat it as informational (e.g. a \"pity!\" toast)\nrather than something to branch business logic on.\n\n---\n\n## Duplicate conversion\n\n```ts\ninterface DuplicateCollectionCurrencyConversion {\n Rarity?: number;\n CollectionCurrencyGranted?: number;\n}\n```\n\nWhen a pack/chest pull is a Collectible the player already owns, instead of\nstacking uselessly it auto-converts into `CollectionCurrencyGranted` (looked\nup by the pulled Collectible's `Rarity` in this list) — that's the\n`CollectionCurrencyEarned` you see on `OpenPackResponse` /\n`OpenCollectionChestResponse`, and it's what funds `openCollectionChest`. This\nis why chests exist: a way to spend \"wasted\" duplicate pulls on guaranteed\nprogress instead.\n\n**Fallback when no rule matches the rarity:** `GetCollectionCurrencyForDuplicate`\nfalls back to `rarity` itself (i.e. a rarity-3 duplicate grants 3 Collection\nCurrency) if `DuplicateConversions` has no entry for that rarity — so an\nincomplete conversion table doesn't silently grant 0, but also won't match\nwhatever curve you intended. Configure every rarity 1-5 explicitly rather than\nrelying on the fallback.\n\nA duplicate normally caps ownership at effectively 1 (the doc comment on\n`UserCollectionState.OwnedCollectibles` calls `>= 2` a rare/transient state —\nconversion is meant to be immediate) but the code path that increments it is\nplain `Dictionary` arithmetic in memory before the Mongo patch, so treat\n`OwnedCollectibles[id]` as \"0, 1, or rarely-briefly more,\" not a strict\nboolean.\n\n---\n\n## SpecialTradeEvents\n\n```ts\ninterface SpecialTradeEventDefinition {\n SpecialTradeEventID?: string;\n StartUtc?: string;\n EndUtc?: string;\n AllowedSpecialCollectibleIDs?: string[];\n SpecialTradeEventDailyTradeLimit?: number;\n}\n```\n\nSpecial-version Collectibles (`HasSpecialVersion: true` on the base\nCollectible, traded with `collectibleIsSpecial: true`) can only move via\n`sendTradeOffer` while an active event's window covers `now` **and** lists\nthat Collectible in `AllowedSpecialCollectibleIDs`. Outside any such window,\noffering a Special is rejected server-side. The event also carries its own\ndaily limit distinct from the title-wide `DailyTradeLimit`.\n\n---\n\n## Player state: UserCollectionState\n\nReturned by `getUserState()`; cached at `client.data.user.state?.Collection`.\n**Loosely typed** (`z.object({}).passthrough()` cast to the interface) —\nunlike the config side, this is not field-validated, so treat it as\nbest-effort and read defensively.\n\n```ts\ninterface UserCollectionState {\n CollectionID?: string;\n SeasonVersion?: number;\n CollectionCurrencyBalance?: number;\n TotalCollectionCurrencyEarned?: number;\n OwnedCollectibles?: Record<string, number>; // CollectibleID -> count owned\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[]; // SetIDs already claimed\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n DailyTradesSent?: number;\n DailyTradesResetDate?: string;\n PendingTradeOfferIDs?: string[];\n PityCounters?: Record<string, UserLootboxPityCounter>; // key = \"{PackTypeID|CollectionChestID}:{RuleID}\"\n}\n```\n\n`SeasonVersion` defaults to `0` when the collection isn't season-linked.\n`PityCounters` (like `OwnedCollectibles`/`OwnedSpecialCollectibles`) is a plain\ndictionary that only gains a key the first time that pool triggers — treat a\nmissing key as counter `0`, not an error.\n\n---\n\n## Season-linked wipe\n\nA `CollectionDefinition` may set `SeasonChainID` to bind itself to a season\nchain (`Season` module). Every Collection action re-derives the \"current\"\n`(activeCollectionID, SeasonVersion)` pair on each call\n(`EnsureCollectionWipedIfNeededAsync` in `Collection.cs`):\n\n- If any season chain has a `LinkedCollectionID` whose window is currently\n active (not paused), that collection is the active one, and\n `SeasonVersion = CycleIndex * 1000 + SeasonOrder` of that window.\n- Otherwise, if the title has no season-linked collection, the **first**\n collection in config-declaration order (`Collections.Keys.First()`) is used\n with `SeasonVersion = 0`.\n\nIf the player's stored `UserCollectionState.CollectionID` /\n`SeasonVersion` doesn't match, the **entire** Collection state is wiped and\nreplaced with a fresh zeroed one (new `CollectionID`, `SeasonVersion`, empty\n`OwnedCollectibles`/`OwnedSpecialCollectibles`/`ClaimedSetRewards`/\n`PendingTradeOfferIDs`, zeroed currency, `IsCollectionCompleted`/\n`GrandPrizeClaimed` reset to `false`) — this happens **lazily**, on the very\nnext Collection call the player makes after the season rolls over, not on a\nschedule. There is no dedicated wipe event; the wiped state is simply what\nthe next `getUserState()` (or any other Collection call) returns. Design\naround this: don't assume a cached `Collection` state survives across a\nsession gap without a fresh fetch, and don't build UI that depends on\n`OwnedCollectibles` persisting across a season boundary for a season-linked\ncollection.\n\n---\n\n## Trade offers\n\n```ts\ninterface CollectionTradeOfferDocument {\n OfferID: string;\n TitleID?: string;\n CollectionID?: string;\n SenderUserID?: string;\n SenderPublicData?: UserPublicDataModel; // sender's public profile snapshot\n OfferedCollectibleID?: string;\n OfferedCollectibleIsSpecial?: boolean;\n ReceiverUserID?: string;\n RequestedCollectibleID?: string; // absent = open/gift offer, no ask-back\n RequestedCollectibleIsSpecial?: boolean;\n Status?: \"Pending\" | \"Accepted\" | \"Declined\" | \"Cancelled\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RespondedAtUtc?: string;\n DeclineReason?: string;\n IsSpecialTradeEvent?: boolean;\n SpecialTradeEventID?: string;\n}\n```\n\n**Preconditions checked by `sendTradeOffer` (`Collection.cs` `SendTradeOffer`),\nin order:** `ReceiverUserID` can't equal your own `UserID` (\"Cannot trade with\nyourself\"); the receiver must be in your **Social.Accepted friends list**\n(\"Receiver must be in your friends list\" — see\n[social-system](../../social-system/SKILL.md)); if `CollectibleIsSpecial` a\nmatching active `SpecialTradeEventDefinition` must exist (\"Special\nCollectibles can only be traded during an active SpecialTradeEvent\"); the\neffective daily limit (event-specific limit if trading a Special during its\nevent window, else the title's `DailyTradeLimit`, lazily reset at UTC\nmidnight) must not already be hit (\"Daily trade limit reached (N/day)\"); you\nmust hold enough of the offered Collectible — **`>= 2`** for a normal\nCollectible (you keep one, offer the spare) or **`>= 1`** for a Special (the\nwhole thing moves, no spare kept back); and the receiver's\n`PendingTradeOfferIDs` must have fewer than **10** entries\n(`MaxPendingIncomingOffers`) — \"Receiver has too many pending trade offers.\"\nThe same `>= 2` (normal) / `>= 1` (Special) ownership check re-runs against\nthe **receiver's** balance for `RequestedCollectibleID` at `acceptTradeOffer`\ntime, since their holdings may have changed since the offer was sent.\n\nLifecycle: `sendTradeOffer` creates a document with `Status: \"Pending\"`,\n`CreatedAtUtc: now`, and `ExpiresAtUtc: now + 168h` (7 days —\n`TradeOfferExpirationHours` in `Collection.cs`, not title-configurable). The\nreceiver calls `getIncomingTradeOffers` to see it, then either\n`acceptTradeOffer` (→ `Status: \"Accepted\"`, both Collectibles swap owners; also\nrejected if `ExpiresAtUtc <= now`, \"Offer has expired\") or `declineTradeOffer`\n(→ `Status: \"Declined\"`). The sender can `cancelTradeOffer` any offer still\n`\"Pending\"` (→ `Status: \"Cancelled\"`).\n\n**`\"Expired\"` is a declared `Status` value the backend never actually\nwrites** — there is no sweep job that flips stale offers to `Expired`.\n`getIncomingTradeOffers` filters server-side to `Status == \"Pending\" &&\nExpiresAtUtc > now`, so an expired incoming offer just silently drops out of\nthat list (it doesn't surface with a distinguishable status). `getMyTradeOffers`\n(outgoing) has **no such filter** — it returns everything you've ever sent for\nthat collection (newest 20), so a lapsed offer you sent still reads\n`Status: \"Pending\"` with an `ExpiresAtUtc` in the past; compare `ExpiresAtUtc`\nagainst the current time yourself if you need to grey it out in a \"my offers\"\nlist. None of the four trade actions mutate `client.data.user` directly (no\n`Resources`, no `Collection` cache patch) — re-fetch `getUserState()` / the\noffer lists to observe the effect.\n\n---\n\n## Responses\n\n```ts\ninterface GrantedCollectible {\n CollectibleID: string;\n Rarity?: number;\n IsSpecial?: boolean;\n IsDuplicate?: boolean;\n CollectionCurrencyConverted?: number; // set when IsDuplicate\n}\n\ninterface OpenPackResponse {\n GrantedCollectibles?: GrantedCollectible[]; // full pull list (incl. duplicates)\n DuplicateCollectibles?: GrantedCollectible[]; // subset that were duplicates\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n NewlyCompletedSetIDs?: string[];\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // pack Cost debit (+ BonusRewardSlots grants)\n TriggeredPity?: unknown[];\n}\n\ninterface OpenCollectionChestResponse {\n GrantedCollectibles?: GrantedCollectible[];\n DuplicateCollectibles?: GrantedCollectible[];\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n Resources?: ResourceOperation; // CollectionCurrencyCost debit (+ bonus grants)\n TriggeredPity?: unknown[];\n}\n\ninterface UseCollectibleJokerResponse {\n GrantedCollectibleID?: string;\n NewlyCompletedSetID?: string;\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // Joker item consumed\n}\n\ninterface ClaimSetRewardResponse {\n SetID: string;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimGrandPrizeResponse {\n Resources?: ResourceOperation;\n}\n\ninterface SendTradeOfferResponse {\n OfferID: string;\n ExpiresAtUtc?: string;\n Resources?: ResourceOperation; // usually absent; trading has no inherent cost\n}\n\ninterface AcceptTradeOfferResponse {\n OfferID: string;\n ReceivedCollectibleID?: string;\n ReceivedIsSpecial?: boolean;\n SentCollectibleID?: string;\n SentCollectibleIsSpecial?: boolean;\n Transfer?: unknown; // server-internal transfer record, not strictly typed\n}\n```\n\n`ClaimSetRewardsBatchResponse` is `BatchItemResult<ClaimSetRewardResponse>[]`\n— see the shared `BatchItemResult<T>` shape\n(`_shared/BatchModels.ts`): `{ Id, Success, Error?, Data? }` per item, one\natomic charge across the whole batch.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|