@idosgames/mcp 0.1.5 → 0.1.7
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 +23 -15
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +5 -5
- 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/purchase-system.json +11 -0
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/reward-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
|
@@ -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
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coop-event-system",
|
|
3
3
|
"description": "Build a cooperative / group event system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.coopEvent (CoopEventService): load coop event chain definitions, look up which event in a chain is currently active, load the player's own coop-event state, join or create a matchmade group, spin/contribute toward the group's shared BuildObjects goal, claim a per-member object reward or the group's grand prize, and leave a group. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a co-op event, group event, team event, alliance-style shared-goal feature, spin-to-contribute mechanic, or otherwise touches client.coopEvent, CoopEventService, CoopEventDefinitions, CoopGroupDocument, UserCoopEventState, or CoopSpinResponse — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: coop-event-system\ndescription: >-\n Build a cooperative / group event system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.coopEvent (CoopEventService):\n load coop event chain definitions, look up which event in a chain is\n currently active, load the player's own coop-event state, join or create a\n matchmade group, spin/contribute toward the group's shared BuildObjects\n goal, claim a per-member object reward or the group's grand prize, and leave\n a group. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants a co-op event, group\n event, team event, alliance-style shared-goal feature, spin-to-contribute\n mechanic, or otherwise touches client.coopEvent, CoopEventService,\n CoopEventDefinitions, CoopGroupDocument, UserCoopEventState, or\n CoopSpinResponse — even if they don't name the module explicitly.\n---\n\n# Coop event system (iDosGames TS SDK)\n\nThe Coop Event module runs time-limited **cooperative group events**: the\ntitle schedules a chain of events, each event matchmakes players into small\ngroups, and the group works together toward a shared goal (currently the\n`BuildObjects` mechanic — each member owns one \"object\" they fill up by\nspinning, and the whole group shares a grand prize once every object is\ndone). Everything is **server-authoritative**: the client asks to join, spin,\nor claim; the backend validates and updates the shared group document; the\nSDK mirrors the confirmed result into a local cache your UI reads. You never\nmutate group or user state yourself.\n\nThis skill is for **using** the production `CoopEventService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (matchmaking window, group capacity, already claimed) — surface the\nerror, don't try to reproduce the check client-side.\n\n## The four data shapes\n\nThis module has more moving parts than a single-player system because of the\ngroup dimension. Keep these four straight:\n\n1. **Definitions** (config, same for every player) — the title's chains of\n coop events: schedule, group size (`PartnerCount`), spin cost, spinner\n odds, per-object completion rewards, grand prize. Fetched with\n `getDefinitions()`.\n2. **Active event info** (config lookup, same for every player watching the\n same chain) — which single event inside a chain is live _right now_, plus\n its computed window. Fetched with `getActiveEvent(coopChainID)`.\n3. **User state** (state, per player) — this player's own coop-event bookkeeping:\n which group they're currently in (if any), which object index in that\n group is _theirs_, and their history of past cycles. Fetched with\n `getUserState()`.\n4. **Group state** (state, per group, shared by every member) — the live\n document every member of a group reads and writes together: the member\n list, each member's contribution counters, the shared `BuildObjects`\n progress for every object, and the group's lifecycle status. Fetched with\n `getGroupState(groupID)`, and also returned by `joinOrCreateGroup`.\n\nA player belongs to **at most one active group per chain** at a time. Their\nown membership pointer (`ActiveGroupID` / `ActiveCoopEventID` /\n`MyObjectIndex`) lives in **user state**; everything about the group itself —\nwho else is in it, whose object is at what progress — lives in **group\nstate**. Render \"my event\" screens from user state + group state together;\nrender \"which event is running\" banners from active-event info.\n\n**Joining** (`joinOrCreateGroup`) either seats the player into an existing\n`Forming`/`Active` group for that chain's current event or spins up a new one\n— the backend's matchmaker decides which; the client just asks to join the\nchain. A group's target size is `1 + PartnerCount` members (`PartnerCount`\ndefaults to 4, i.e. a 5-member group), and if matchmaking doesn't fill it in\ntime the backend seats bots into the empty slots — see Gotchas. **Spinning**\n(`spin`) is the shared contribute action: it costs the event's `SpinCost`,\nrolls a sector on the spinner table, and adds that sector's progress to the\n_player's own_ object in the group's `BuildObjects` tree. **Claiming** has two\ndistinct steps because the mechanic has two distinct payouts:\n`claimObjectReward` pays out **one member's own completed object** (each\nmember claims their own once it's full), while `claimGrandPrize` pays out the\n**group-wide** prize and is only claimable once every object in the group is\ncomplete. Leaving (`leaveGroup`) drops the player's membership; it does not\ndelete the group for the remaining members.\n\nFor the full field-by-field shape of chains, events, the `BuildObjects`\nmechanic, the spinner-weight formula, and the group document, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config and shared\ngroup state.\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 coopEvents = client.coopEvent; // the CoopEventService\n```\n\nEvery coop-event method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args, e.g. missing id), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window),\n`\"connection\"` (transient, offer Retry), `\"validation\"` (response/schema\ndrift), or `\"server\"` (backend rejected it — `error` carries the\nhuman-readable reason straight from the backend, e.g. `\"No active coop event\nin this chain.\"`, `\"Matchmaking failed after retries. Please try again.\"`,\n`\"Your object is already completed. Claim your reward.\"`, `\"Grand Prize\nalready claimed.\"`).\n\n| Method | Purpose | `data` on success |\n| -------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's coop event chains (config). | `CoopEventDefinitions` |\n| `getActiveEvent(coopChainID)` | Look up which event in a chain is currently live. | `ActiveCoopEventInfo` (`CoopChainID`, + untyped extra fields — see below) |\n| `getUserState()` | Load this player's own coop-event bookkeeping (state). | `CoopUserStateResponse` (`UserState`, `ActiveGroup`) |\n| `getGroupState(groupID)` | Load the live shared document for a specific group. | `CoopGroupStateResponse` (`Group`, `SecondsRemaining`) |\n| `joinOrCreateGroup(coopChainID)` | Join an existing group for the chain's active event, or start a new one. | `CoopGroupStateResponse` (`Group`) |\n| `spin(coopChainID, groupID)` | Spend the spin cost, roll the spinner, add progress to your own object. | `CoopSpinResponse` (`Sector`, `NewProgress`, `ObjectCompleted`, `AllObjectsCompleted`) |\n| `claimObjectReward(groupID)` | Claim the completion reward for your own finished object. | `CoopClaimRewardResponse` (`RewardType`, `Resources`) |\n| `claimGrandPrize(groupID)` | Claim the group-wide grand prize once every object is complete. | `CoopClaimRewardResponse` (`RewardType`, `Resources`) |\n| `leaveGroup(groupID?)` | Leave your currently-active group. | `CoopLeaveGroupResponse` (`Success`) |\n\nOn success, each method also **mirrors the confirmed change into the cache\nand emits an event** — you don't apply anything by hand. `spin`,\n`claimObjectReward`, and `claimGrandPrize` all carry a `Resources`\n(`ResourceOperation`) payload that is already applied to the cached\ncurrency/item balances, so read updated balances straight from the cache\nrather than off the response. `joinOrCreateGroup` and `claimGrandPrize` also\npatch the player's `ActiveGroupID`/`ActiveCoopEventID`/`MyObjectIndex`\npointer in user state (join sets it optimistically to the joined group with\nan unresolved object index of `-1`; claiming the grand prize clears it back\nto no active group — the authoritative object index itself always comes from\n`getUserState()`, not from the optimistic patch).\n\n**`getActiveEvent`'s typed model is thinner than the wire response.** The\nzod schema (`zActiveCoopEventInfo`) only strongly types `CoopChainID`; the\nbackend actually returns `CycleIndex`, `EventOrder`, `EventDef` (the full\n`CoopEventDefinition` for the live event — spin cost, spinner table, objects,\ngrand prize), `ComputedStartUtc`, `ComputedEndUtc`, and `SecondsRemaining` too.\nBecause every model in this SDK keeps `.passthrough()`, those fields **are**\npresent on the object at runtime — they're just untyped (`unknown` unless you\ncast). If you need `EventDef` to preview spin cost/odds before joining, read\nit off the response with an explicit cast, or fetch `getDefinitions()` and\nlook the event up yourself by chain + `CoopEventID`.\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// This player's own coop-event bookkeeping (only present after getUserState()\n// or after join/spin/claim/leave patch it):\nconst myCoop = client.data.user.state?.CoopEvent;\nmyCoop?.ActiveGroupID; // group I'm currently in, or null/undefined\nmyCoop?.ActiveCoopEventID; // which event that group belongs to\nmyCoop?.MyObjectIndex; // which BuildObjects object is mine (-1 = unresolved)\nmyCoop?.History; // past cycles: GroupID, FinalStatus, GrandPrizeReceived, ...\n\n// Definitions (cached after getDefinitions()):\nimport type { CoopEventDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CoopEventDefinitions>(\"CoopEvent\");\n```\n\nGroup state (the shared document) is **not** written into\n`client.data.user.state` — it's returned directly from `getGroupState`,\n`joinOrCreateGroup`, and the `coopEvent:groupStateLoaded` /\n`coopEvent:groupJoined` events. Keep the latest `CoopGroupDocument` you\nreceived in your own component/store state and refresh it by calling\n`getGroupState(groupID)` again (e.g. on a poll or after your own spin).\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `coopEvent:definitionsLoaded` → `CoopEventDefinitions`\n- `coopEvent:activeEventLoaded` → `ActiveCoopEventInfo`\n- `coopEvent:userStateLoaded` → `CoopUserStateResponse`\n- `coopEvent:groupStateLoaded` → `CoopGroupStateResponse`\n- `coopEvent:groupJoined` → `CoopGroupStateResponse`\n- `coopEvent:spinCompleted` → `CoopSpinResponse`\n- `coopEvent:objectRewardClaimed` → `CoopClaimRewardResponse`\n- `coopEvent:grandPrizeClaimed` → `CoopClaimRewardResponse`\n- `coopEvent:groupLeft` → `CoopLeaveGroupResponse`\n\nThe coarse `user:coopEventUpdated` (and `user:anyUpdated`) also fire whenever\n`getUserState`, `joinOrCreateGroup`, `claimGrandPrize`, or `leaveGroup`\nwrites to the cached user coop-event state — handy for a \"re-render\neverything\" hook. Note `spin` and `claimObjectReward` do **not** touch user\nstate (they only affect the shared group document and resource balances), so\nthey emit their own `coopEvent:*` event and the resource-balance events, but\nnot `user:coopEventUpdated`.\n\n```ts\nconst off = client.on(\"coopEvent:spinCompleted\", (r) => {\n console.log(`Rolled ${r.Sector?.DisplayName}, +${r.ProgressDelta} progress`);\n if (r.ObjectCompleted) console.log(\"Your object is done!\");\n if (r.AllObjectsCompleted)\n console.log(\"Whole group is done — claim the grand prize!\");\n});\n// later: off();\n```\n\n## Recipes\n\n### Golden path: load, join, contribute, claim\n\n```ts\nawait client.coopEvent.getDefinitions();\nconst active = await client.coopEvent.getActiveEvent(\"spring-coop-chain\");\nif (!active.ok) return showError(active.error);\n\n// Join (or get seated into) a group for this chain.\nconst joined = await client.coopEvent.joinOrCreateGroup(\"spring-coop-chain\");\nif (!joined.ok) return showError(joined.error); // e.g. \"Matchmaking failed after retries. Please try again.\"\nconst groupID = joined.data.Group?.GroupID;\nif (!groupID) return;\n\n// Spin to contribute toward your own object.\n// Third arg is the spin count (default 1). Only spins that ACTUALLY happen are charged:\n// the run stops at the spin that completes the object — compare SpinsUsed vs RequestedSpins.\nconst spin = await client.coopEvent.spin(\"spring-coop-chain\", groupID, 10);\nif (!spin.ok) return showError(spin.error); // e.g. \"SpinCost not configured.\"\nspin.data.NewProgress; // your object's progress after this spin\nspin.data.MaxProgress;\n\nif (spin.data.ObjectCompleted) {\n const reward = await client.coopEvent.claimObjectReward(groupID);\n if (!reward.ok) return showError(reward.error);\n // reward.data.Resources already applied to cached balances.\n}\n\nif (spin.data.AllObjectsCompleted) {\n const grand = await client.coopEvent.claimGrandPrize(groupID);\n if (!grand.ok) return showError(grand.error); // e.g. \"Grand Prize already claimed.\"\n}\n```\n\n### Checking group progress and other members\n\n```ts\nconst group = await client.coopEvent.getGroupState(groupID);\nif (!group.ok) return showError(group.error);\n\ngroup.data.SecondsRemaining; // time left before the group's window expires\n\nfor (const member of group.data.Group?.Members ?? []) {\n member.PublicData?.Username;\n member.SpinsCount;\n member.TokensSpent;\n member.MemberStatus; // \"Active\" | \"Left\" | \"Replaced\"\n member.BuildObjectsProgress?.ObjectIndex; // which object is theirs\n member.BuildObjectsProgress?.ObjectCompletionRewardClaimed;\n}\n\nfor (const obj of group.data.Group?.BuildObjectsState?.Objects ?? []) {\n obj.OwnerUserID;\n obj.CurrentProgress;\n obj.MaxProgress;\n obj.IsCompleted;\n}\n```\n\nPoll `getGroupState` (or re-fetch after your own actions) to keep a \"my\ngroup\" screen showing teammates' progress — there is no group-wide push\nevent, so other members' spins only show up once you re-fetch. Note that\n`getGroupState` (and `joinOrCreateGroup`) also lazily advance bot members'\nprogress server-side on each call — see Gotchas.\n\n### Claiming an object reward vs. the grand prize\n\n```ts\n// Your own object reward — claimable per member, independently of teammates.\nconst objectReward = await client.coopEvent.claimObjectReward(groupID);\nif (!objectReward.ok) return showError(objectReward.error); // e.g. \"Your object is not completed yet.\"\nobjectReward.data.RewardType; // \"ObjectCompletion\"\n\n// Grand prize — one claim per player per group, requires every object done.\nconst grandPrize = await client.coopEvent.claimGrandPrize(groupID);\nif (!grandPrize.ok) return showError(grandPrize.error); // e.g. \"Grand Prize is not available (group status: Active).\"\ngrandPrize.data.RewardType; // \"GrandPrize\"\n```\n\nDon't gate the \"claim object reward\" button on the whole group finishing —\nit only depends on _your_ object. Gate the grand-prize button on\n`AllObjectsCompleted` (from the last `spin`/`getGroupState` response) or on\nwalking `BuildObjectsState.Objects` and checking every `IsCompleted` — note\nthe backend additionally requires the group's own `Status` to have already\nflipped to `\"Completed\"` before `claimGrandPrize` will accept the call, which\nnormally happens automatically the instant the last object finishes.\n\n### Group lifecycle: join, then leave\n\n```ts\nconst joined = await client.coopEvent.joinOrCreateGroup(\"spring-coop-chain\");\nif (!joined.ok) return showError(joined.error);\n\n// ... play the event ...\n\nconst left = await client.coopEvent.leaveGroup(joined.data.Group?.GroupID);\nif (!left.ok) return showError(left.error);\n// client.data.user.state?.CoopEvent now has ActiveGroupID/ActiveCoopEventID\n// cleared (null) and MyObjectIndex reset to -1.\n```\n\n`leaveGroup`'s `groupID` argument is optional — omit it to leave whatever\ngroup the backend has on record as the player's active one. Leaving does not\nun-claim anything already claimed and does not affect other members' groups;\nre-calling `joinOrCreateGroup` afterward may seat the player into a fresh\ngroup (their old object progress belongs to the group they left, not to\nthem).\n\n## Gotchas\n\n- **Guard against double-submit.** Every authenticated request gets a fresh\n `RelatedEntityID` idempotency key, so two separate calls are two real\n operations — a double-clicked \"Spin\" can charge twice. Disable the control\n while a call is in flight. (Firing the same endpoint again within the\n throttle window, default 600 ms, is rejected with `reason: \"throttled\"`\n rather than duplicated, but don't rely on that for correctness.)\n- **User state and group state are separate caches.** Only `getUserState`,\n `joinOrCreateGroup`, `claimGrandPrize`, and `leaveGroup` touch\n `client.data.user.state?.CoopEvent`. `getGroupState`, `spin`, and\n `claimObjectReward` never write there — they only return data and (for\n `spin`/`claimObjectReward`) apply resource balances. Don't expect\n `user:coopEventUpdated` to fire after a spin.\n- **`MyObjectIndex` from `joinOrCreateGroup` is a placeholder, not the\n truth.** The service optimistically patches it to `-1` on join because the\n real assigned object index isn't known client-side yet — call\n `getUserState()` to get the authoritative value before relying on it.\n- **`getActiveEvent`'s typed shape omits the useful fields.** Only\n `CoopChainID` is strongly typed; `EventDef`, `CycleIndex`, `EventOrder`,\n `ComputedStartUtc`/`ComputedEndUtc`, and `SecondsRemaining` ride along\n untyped via passthrough. Cast explicitly if you need them, or resolve the\n live event from `getDefinitions()` config instead.\n- **Group state has no push updates.** There's no live event for \"a\n teammate just spun\" — `getGroupState` (or the response of your own\n `spin`/`joinOrCreateGroup`) is a snapshot. Poll it if you want a\n progress bar for other members to move.\n- **Object reward and grand prize are claimed independently, and each\n guards against re-claiming.** `CoopBuildObjectsMemberState\n.ObjectCompletionRewardClaimed` and `CoopGroupMember.GrandPrizeClaimed` are\n the server's own once-only guards — a repeat call to `claimObjectReward`\n fails with `\"Object completion reward already claimed.\"` (or, on a raw\n race, `\"Claim failed: already claimed or object not completed.\"`), and a\n repeat `claimGrandPrize` fails with `\"Grand Prize already claimed.\"` (or\n `\"Grand Prize claim failed: already claimed or group not completed.\"`).\n- **Groups have a lifecycle beyond \"you're in it\".** `CoopGroupDocument\n.Status` is one of `Forming | Active | Completed | Failed | Expired`\n (`CoopGroupStatus`) and carries `ExpiresAtUtc` / `CreatedAtUtc`. A group\n flips to `Failed` the moment its timer expires with objects unfinished — no\n Grand Prize is granted for a `Failed` group. Surface `Status` and\n `SecondsRemaining` in the UI rather than assuming a joined group stays\n playable indefinitely.\n- **Unfilled groups get backfilled with bots, not left waiting forever.**\n Each event configures `MatchmakingTimeoutMinutes` (how long a `Forming`\n group waits for real players) and `MemberGracePeriodMinutes` (how long a\n vacated slot stays reserved after a member leaves). Once the matchmaking\n timeout passes, the next `getGroupState`/`getUserState`/`joinOrCreateGroup`\n call lazily fills every remaining slot with a bot and flips the group to\n `Active`. Bots (`CoopGroupMember.IsBot === true`) don't really spin — the\n backend deterministically simulates their progress (roughly 60–90% final\n efficiency, linearly interpolated against event time elapsed) on every\n read, so their progress bars advance on their own between your calls.\n- **Members can leave or be replaced without the group disappearing.**\n `CoopGroupMember.MemberStatus` is `Active | Left | Replaced`\n (`CoopMemberStatus`) — a member who leaves keeps their row in `Members`\n with `LeftAtUtc` set rather than being removed, so don't assume\n `Members.length` equals the current headcount; filter on\n `MemberStatus === \"Active\"`.\n- **`spin` only works for `BuildObjects` events.** The config supports a\n second `EventType`, `BossAttack`, reserved for a future mechanic; calling\n `spin` against a chain whose live event isn't `BuildObjects` fails with\n `\"Spin is only available for BuildObjects events.\"` — check `EventType`\n before showing a spin button.\n- **Render from the cache, handle the error from the result.** The happy\n path updates the cache + emits an event; the failure path gives you\n `reason` + `error`. Use `reason` to decide behavior (retry on\n `\"connection\"`, re-auth on `\"unauthorized\"`, toast the `error` on\n `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the `BuildObjects` mechanic and spinner-table shape, the group\ndocument tree, the spinner-weight and bot-simulation formulas, and how the\nshared `ResourceConsume`/`ResourceGrant`/`ResourceOperation` types apply\nhere. Read it when building config-driven UI (spin cost previews, spinner\nodds, object progress bars) or when you need the exact shape of the group\ndocument.\n",
|
|
4
|
+
"content": "---\nname: coop-event-system\ndescription: >-\n Build a cooperative / group event system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.coopEvent (CoopEventService):\n load coop event chain definitions, look up which event in a chain is\n currently active, load the player's own coop-event state, join or create a\n matchmade group, spin/contribute toward the group's shared BuildObjects\n goal, claim a per-member object reward or the group's grand prize, and leave\n a group. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants a co-op event, group\n event, team event, alliance-style shared-goal feature, spin-to-contribute\n mechanic, or otherwise touches client.coopEvent, CoopEventService,\n CoopEventDefinitions, CoopGroupDocument, UserCoopEventState, or\n CoopSpinResponse — even if they don't name the module explicitly.\n---\n\n# Coop event system (iDosGames TS SDK)\n\nThe Coop Event module runs time-limited **cooperative group events**: the\ntitle schedules a chain of events, each event matchmakes players into small\ngroups, and the group works together toward a shared goal (currently the\n`BuildObjects` mechanic — each member owns one \"object\" they fill up by\nspinning, and the whole group shares a grand prize once every object is\ndone). Everything is **server-authoritative**: the client asks to join, spin,\nor claim; the backend validates and updates the shared group document; the\nSDK mirrors the confirmed result into a local cache your UI reads. You never\nmutate group or user state yourself.\n\nThis skill is for **using** the production `CoopEventService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (matchmaking window, group capacity, already claimed) — surface the\nerror, don't try to reproduce the check client-side.\n\n## The four data shapes\n\nThis module has more moving parts than a single-player system because of the\ngroup dimension. Keep these four straight:\n\n1. **Definitions** (config, same for every player) — the title's chains of\n coop events: schedule, group size (`PartnerCount`), spin cost, spinner\n odds, per-object completion rewards, grand prize. Fetched with\n `getDefinitions()`.\n2. **Active event info** (config lookup, same for every player watching the\n same chain) — which single event inside a chain is live _right now_, plus\n its computed window. Fetched with `getActiveEvent(coopChainID)`.\n3. **User state** (state, per player) — this player's own coop-event bookkeeping:\n which group they're currently in (if any), which object index in that\n group is _theirs_, and their history of past cycles. Fetched with\n `getUserState()`.\n4. **Group state** (state, per group, shared by every member) — the live\n document every member of a group reads and writes together: the member\n list, each member's contribution counters, the shared `BuildObjects`\n progress for every object, and the group's lifecycle status. Fetched with\n `getGroupState(groupID)`, and also returned by `joinOrCreateGroup`.\n\nA player belongs to **at most one active group per chain** at a time. Their\nown membership pointer (`ActiveGroupID` / `ActiveCoopEventID` /\n`MyObjectIndex`) lives in **user state**; everything about the group itself —\nwho else is in it, whose object is at what progress — lives in **group\nstate**. Render \"my event\" screens from user state + group state together;\nrender \"which event is running\" banners from active-event info.\n\n**Joining** (`joinOrCreateGroup`) either seats the player into an existing\n`Forming`/`Active` group for that chain's current event or spins up a new one\n— the backend's matchmaker decides which; the client just asks to join the\nchain. A group's target size is `1 + PartnerCount` members (`PartnerCount`\ndefaults to 4, i.e. a 5-member group), and if matchmaking doesn't fill it in\ntime the backend seats bots into the empty slots — see Gotchas. **Spinning**\n(`spin`) is the shared contribute action: it costs the selected option of the\nevent's `PriceOptions`,\nrolls a sector on the spinner table, and adds that sector's progress to the\n_player's own_ object in the group's `BuildObjects` tree. **Claiming** has two\ndistinct steps because the mechanic has two distinct payouts:\n`claimObjectReward` pays out **one member's own completed object** (each\nmember claims their own once it's full), while `claimGrandPrize` pays out the\n**group-wide** prize and is only claimable once every object in the group is\ncomplete. Leaving (`leaveGroup`) drops the player's membership; it does not\ndelete the group for the remaining members.\n\nFor the full field-by-field shape of chains, events, the `BuildObjects`\nmechanic, the spinner-weight formula, and the group document, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config and shared\ngroup state.\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 coopEvents = client.coopEvent; // the CoopEventService\n```\n\nEvery coop-event method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args, e.g. missing id), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window),\n`\"connection\"` (transient, offer Retry), `\"validation\"` (response/schema\ndrift), or `\"server\"` (backend rejected it — `error` carries the\nhuman-readable reason straight from the backend, e.g. `\"No active coop event\nin this chain.\"`, `\"Matchmaking failed after retries. Please try again.\"`,\n`\"Your object is already completed. Claim your reward.\"`, `\"Grand Prize\nalready claimed.\"`).\n\n| Method | Purpose | `data` on success |\n| -------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's coop event chains (config). | `CoopEventDefinitions` |\n| `getActiveEvent(coopChainID)` | Look up which event in a chain is currently live. | `ActiveCoopEventInfo` (`CoopChainID`, + untyped extra fields — see below) |\n| `getUserState()` | Load this player's own coop-event bookkeeping (state). | `CoopUserStateResponse` (`UserState`, `ActiveGroup`) |\n| `getGroupState(groupID)` | Load the live shared document for a specific group. | `CoopGroupStateResponse` (`Group`, `SecondsRemaining`) |\n| `joinOrCreateGroup(coopChainID)` | Join an existing group for the chain's active event, or start a new one. | `CoopGroupStateResponse` (`Group`) |\n| `spin(coopChainID, groupID)` | Spend the spin cost, roll the spinner, add progress to your own object. | `CoopSpinResponse` (`Sector`, `NewProgress`, `ObjectCompleted`, `AllObjectsCompleted`) |\n| `claimObjectReward(groupID)` | Claim the completion reward for your own finished object. | `CoopClaimRewardResponse` (`RewardType`, `Resources`) |\n| `claimGrandPrize(groupID)` | Claim the group-wide grand prize once every object is complete. | `CoopClaimRewardResponse` (`RewardType`, `Resources`) |\n| `leaveGroup(groupID?)` | Leave your currently-active group. | `CoopLeaveGroupResponse` (`Success`) |\n\nOn success, each method also **mirrors the confirmed change into the cache\nand emits an event** — you don't apply anything by hand. `spin`,\n`claimObjectReward`, and `claimGrandPrize` all carry a `Resources`\n(`ResourceOperation`) payload that is already applied to the cached\ncurrency/item balances, so read updated balances straight from the cache\nrather than off the response. `joinOrCreateGroup` and `claimGrandPrize` also\npatch the player's `ActiveGroupID`/`ActiveCoopEventID`/`MyObjectIndex`\npointer in user state (join sets it optimistically to the joined group with\nan unresolved object index of `-1`; claiming the grand prize clears it back\nto no active group — the authoritative object index itself always comes from\n`getUserState()`, not from the optimistic patch).\n\n**`getActiveEvent`'s typed model is thinner than the wire response.** The\nzod schema (`zActiveCoopEventInfo`) only strongly types `CoopChainID`; the\nbackend actually returns `CycleIndex`, `EventOrder`, `EventDef` (the full\n`CoopEventDefinition` for the live event — spin cost, spinner table, objects,\ngrand prize), `ComputedStartUtc`, `ComputedEndUtc`, and `SecondsRemaining` too.\nBecause every model in this SDK keeps `.passthrough()`, those fields **are**\npresent on the object at runtime — they're just untyped (`unknown` unless you\ncast). If you need `EventDef` to preview spin cost/odds before joining, read\nit off the response with an explicit cast, or fetch `getDefinitions()` and\nlook the event up yourself by chain + `CoopEventID`.\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// This player's own coop-event bookkeeping (only present after getUserState()\n// or after join/spin/claim/leave patch it):\nconst myCoop = client.data.user.state?.CoopEvent;\nmyCoop?.ActiveGroupID; // group I'm currently in, or null/undefined\nmyCoop?.ActiveCoopEventID; // which event that group belongs to\nmyCoop?.MyObjectIndex; // which BuildObjects object is mine (-1 = unresolved)\nmyCoop?.History; // past cycles: GroupID, FinalStatus, GrandPrizeReceived, ...\n\n// Definitions (cached after getDefinitions()):\nimport type { CoopEventDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CoopEventDefinitions>(\"CoopEvent\");\n```\n\nGroup state (the shared document) is **not** written into\n`client.data.user.state` — it's returned directly from `getGroupState`,\n`joinOrCreateGroup`, and the `coopEvent:groupStateLoaded` /\n`coopEvent:groupJoined` events. Keep the latest `CoopGroupDocument` you\nreceived in your own component/store state and refresh it by calling\n`getGroupState(groupID)` again (e.g. on a poll or after your own spin).\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `coopEvent:definitionsLoaded` → `CoopEventDefinitions`\n- `coopEvent:activeEventLoaded` → `ActiveCoopEventInfo`\n- `coopEvent:userStateLoaded` → `CoopUserStateResponse`\n- `coopEvent:groupStateLoaded` → `CoopGroupStateResponse`\n- `coopEvent:groupJoined` → `CoopGroupStateResponse`\n- `coopEvent:spinCompleted` → `CoopSpinResponse`\n- `coopEvent:objectRewardClaimed` → `CoopClaimRewardResponse`\n- `coopEvent:grandPrizeClaimed` → `CoopClaimRewardResponse`\n- `coopEvent:groupLeft` → `CoopLeaveGroupResponse`\n\nThe coarse `user:coopEventUpdated` (and `user:anyUpdated`) also fire whenever\n`getUserState`, `joinOrCreateGroup`, `claimGrandPrize`, or `leaveGroup`\nwrites to the cached user coop-event state — handy for a \"re-render\neverything\" hook. Note `spin` and `claimObjectReward` do **not** touch user\nstate (they only affect the shared group document and resource balances), so\nthey emit their own `coopEvent:*` event and the resource-balance events, but\nnot `user:coopEventUpdated`.\n\n```ts\nconst off = client.on(\"coopEvent:spinCompleted\", (r) => {\n console.log(`Rolled ${r.Sector?.DisplayName}, +${r.ProgressDelta} progress`);\n if (r.ObjectCompleted) console.log(\"Your object is done!\");\n if (r.AllObjectsCompleted)\n console.log(\"Whole group is done — claim the grand prize!\");\n});\n// later: off();\n```\n\n## Recipes\n\n### Golden path: load, join, contribute, claim\n\n```ts\nawait client.coopEvent.getDefinitions();\nconst active = await client.coopEvent.getActiveEvent(\"spring-coop-chain\");\nif (!active.ok) return showError(active.error);\n\n// Join (or get seated into) a group for this chain.\nconst joined = await client.coopEvent.joinOrCreateGroup(\"spring-coop-chain\");\nif (!joined.ok) return showError(joined.error); // e.g. \"Matchmaking failed after retries. Please try again.\"\nconst groupID = joined.data.Group?.GroupID;\nif (!groupID) return;\n\n// Spin to contribute toward your own object.\n// Third arg is the spin count (default 1). Only spins that ACTUALLY happen are charged:\n// the run stops at the spin that completes the object — compare SpinsUsed vs RequestedSpins.\n// Fourth arg picks the way to pay (`PriceOption.OptionID`); omit it for the first\n// option available on this platform.\nconst spin = await client.coopEvent.spin(\"spring-coop-chain\", groupID, 10);\nif (!spin.ok) return showError(spin.error); // e.g. \"Spin cost is empty.\"\nspin.data.NewProgress; // your object's progress after this spin\nspin.data.MaxProgress;\n\nif (spin.data.ObjectCompleted) {\n const reward = await client.coopEvent.claimObjectReward(groupID);\n if (!reward.ok) return showError(reward.error);\n // reward.data.Resources already applied to cached balances.\n}\n\nif (spin.data.AllObjectsCompleted) {\n const grand = await client.coopEvent.claimGrandPrize(groupID);\n if (!grand.ok) return showError(grand.error); // e.g. \"Grand Prize already claimed.\"\n}\n```\n\n### Checking group progress and other members\n\n```ts\nconst group = await client.coopEvent.getGroupState(groupID);\nif (!group.ok) return showError(group.error);\n\ngroup.data.SecondsRemaining; // time left before the group's window expires\n\nfor (const member of group.data.Group?.Members ?? []) {\n member.PublicData?.Username;\n member.SpinsCount;\n member.TokensSpent;\n member.MemberStatus; // \"Active\" | \"Left\" | \"Replaced\"\n member.BuildObjectsProgress?.ObjectIndex; // which object is theirs\n member.BuildObjectsProgress?.ObjectCompletionRewardClaimed;\n}\n\nfor (const obj of group.data.Group?.BuildObjectsState?.Objects ?? []) {\n obj.OwnerUserID;\n obj.CurrentProgress;\n obj.MaxProgress;\n obj.IsCompleted;\n}\n```\n\nPoll `getGroupState` (or re-fetch after your own actions) to keep a \"my\ngroup\" screen showing teammates' progress — there is no group-wide push\nevent, so other members' spins only show up once you re-fetch. Note that\n`getGroupState` (and `joinOrCreateGroup`) also lazily advance bot members'\nprogress server-side on each call — see Gotchas.\n\n### Claiming an object reward vs. the grand prize\n\n```ts\n// Your own object reward — claimable per member, independently of teammates.\nconst objectReward = await client.coopEvent.claimObjectReward(groupID);\nif (!objectReward.ok) return showError(objectReward.error); // e.g. \"Your object is not completed yet.\"\nobjectReward.data.RewardType; // \"ObjectCompletion\"\n\n// Grand prize — one claim per player per group, requires every object done.\nconst grandPrize = await client.coopEvent.claimGrandPrize(groupID);\nif (!grandPrize.ok) return showError(grandPrize.error); // e.g. \"Grand Prize is not available (group status: Active).\"\ngrandPrize.data.RewardType; // \"GrandPrize\"\n```\n\nDon't gate the \"claim object reward\" button on the whole group finishing —\nit only depends on _your_ object. Gate the grand-prize button on\n`AllObjectsCompleted` (from the last `spin`/`getGroupState` response) or on\nwalking `BuildObjectsState.Objects` and checking every `IsCompleted` — note\nthe backend additionally requires the group's own `Status` to have already\nflipped to `\"Completed\"` before `claimGrandPrize` will accept the call, which\nnormally happens automatically the instant the last object finishes.\n\n### Group lifecycle: join, then leave\n\n```ts\nconst joined = await client.coopEvent.joinOrCreateGroup(\"spring-coop-chain\");\nif (!joined.ok) return showError(joined.error);\n\n// ... play the event ...\n\nconst left = await client.coopEvent.leaveGroup(joined.data.Group?.GroupID);\nif (!left.ok) return showError(left.error);\n// client.data.user.state?.CoopEvent now has ActiveGroupID/ActiveCoopEventID\n// cleared (null) and MyObjectIndex reset to -1.\n```\n\n`leaveGroup`'s `groupID` argument is optional — omit it to leave whatever\ngroup the backend has on record as the player's active one. Leaving does not\nun-claim anything already claimed and does not affect other members' groups;\nre-calling `joinOrCreateGroup` afterward may seat the player into a fresh\ngroup (their old object progress belongs to the group they left, not to\nthem).\n\n## Gotchas\n\n- **Guard against double-submit.** Every authenticated request gets a fresh\n `RelatedEntityID` idempotency key, so two separate calls are two real\n operations — a double-clicked \"Spin\" can charge twice. Disable the control\n while a call is in flight. (Firing the same endpoint again within the\n throttle window, default 600 ms, is rejected with `reason: \"throttled\"`\n rather than duplicated, but don't rely on that for correctness.)\n- **User state and group state are separate caches.** Only `getUserState`,\n `joinOrCreateGroup`, `claimGrandPrize`, and `leaveGroup` touch\n `client.data.user.state?.CoopEvent`. `getGroupState`, `spin`, and\n `claimObjectReward` never write there — they only return data and (for\n `spin`/`claimObjectReward`) apply resource balances. Don't expect\n `user:coopEventUpdated` to fire after a spin.\n- **`MyObjectIndex` from `joinOrCreateGroup` is a placeholder, not the\n truth.** The service optimistically patches it to `-1` on join because the\n real assigned object index isn't known client-side yet — call\n `getUserState()` to get the authoritative value before relying on it.\n- **`getActiveEvent`'s typed shape omits the useful fields.** Only\n `CoopChainID` is strongly typed; `EventDef`, `CycleIndex`, `EventOrder`,\n `ComputedStartUtc`/`ComputedEndUtc`, and `SecondsRemaining` ride along\n untyped via passthrough. Cast explicitly if you need them, or resolve the\n live event from `getDefinitions()` config instead.\n- **Group state has no push updates.** There's no live event for \"a\n teammate just spun\" — `getGroupState` (or the response of your own\n `spin`/`joinOrCreateGroup`) is a snapshot. Poll it if you want a\n progress bar for other members to move.\n- **Object reward and grand prize are claimed independently, and each\n guards against re-claiming.** `CoopBuildObjectsMemberState\n.ObjectCompletionRewardClaimed` and `CoopGroupMember.GrandPrizeClaimed` are\n the server's own once-only guards — a repeat call to `claimObjectReward`\n fails with `\"Object completion reward already claimed.\"` (or, on a raw\n race, `\"Claim failed: already claimed or object not completed.\"`), and a\n repeat `claimGrandPrize` fails with `\"Grand Prize already claimed.\"` (or\n `\"Grand Prize claim failed: already claimed or group not completed.\"`).\n- **Groups have a lifecycle beyond \"you're in it\".** `CoopGroupDocument\n.Status` is one of `Forming | Active | Completed | Failed | Expired`\n (`CoopGroupStatus`) and carries `ExpiresAtUtc` / `CreatedAtUtc`. A group\n flips to `Failed` the moment its timer expires with objects unfinished — no\n Grand Prize is granted for a `Failed` group. Surface `Status` and\n `SecondsRemaining` in the UI rather than assuming a joined group stays\n playable indefinitely.\n- **Unfilled groups get backfilled with bots, not left waiting forever.**\n Each event configures `MatchmakingTimeoutMinutes` (how long a `Forming`\n group waits for real players) and `MemberGracePeriodMinutes` (how long a\n vacated slot stays reserved after a member leaves). Once the matchmaking\n timeout passes, the next `getGroupState`/`getUserState`/`joinOrCreateGroup`\n call lazily fills every remaining slot with a bot and flips the group to\n `Active`. Bots (`CoopGroupMember.IsBot === true`) don't really spin — the\n backend deterministically simulates their progress (roughly 60–90% final\n efficiency, linearly interpolated against event time elapsed) on every\n read, so their progress bars advance on their own between your calls.\n- **Members can leave or be replaced without the group disappearing.**\n `CoopGroupMember.MemberStatus` is `Active | Left | Replaced`\n (`CoopMemberStatus`) — a member who leaves keeps their row in `Members`\n with `LeftAtUtc` set rather than being removed, so don't assume\n `Members.length` equals the current headcount; filter on\n `MemberStatus === \"Active\"`.\n- **`spin` only works for `BuildObjects` events.** The config supports a\n second `EventType`, `BossAttack`, reserved for a future mechanic; calling\n `spin` against a chain whose live event isn't `BuildObjects` fails with\n `\"Spin is only available for BuildObjects events.\"` — check `EventType`\n before showing a spin button.\n- **Render from the cache, handle the error from the result.** The happy\n path updates the cache + emits an event; the failure path gives you\n `reason` + `error`. Use `reason` to decide behavior (retry on\n `\"connection\"`, re-auth on `\"unauthorized\"`, toast the `error` on\n `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the `BuildObjects` mechanic and spinner-table shape, the group\ndocument tree, the spinner-weight and bot-simulation formulas, and how the\nshared `ResourceConsume`/`ResourceGrant`/`ResourceOperation` types apply\nhere. Read it when building config-driven UI (spin cost previews, spinner\nodds, object progress bars) or when you need the exact shape of the group\ndocument.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Coop event data model — reference\n\nFull shape of the config (Definitions), the active-event lookup, player state,\nand the shared group document, plus the spinner-weight, progress-roll, and\nbot-simulation formulas the backend actually runs. All of these are **strictly\ntyped in the SDK** where noted below — `CoopEventDefinitions` and its nested\nblocks are exported from `@idosgames/core` with `.passthrough()` schemas, so a\nfield the backend adds later still round-trips even though it may not appear\nin the TS type. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CoopEventDefinitions](#config-coopeventdefinitions) — what `getDefinitions()` returns\n- [CoopEventChainDefinition](#coopeventchaindefinition)\n- [CoopEventDefinition](#coopeventdefinition-one-event-in-a-chain)\n- [CoopBuildObjectsDefinition + spinner formula](#coopbuildobjectsdefinition--spinner-formula)\n- [ActiveCoopEventInfo (getActiveEvent)](#activecoopeventinfo-getactiveevent)\n- [Player state: UserCoopEventState](#player-state-usercoopeventstate)\n- [Group state: CoopEventGroupDocument](#group-state-coopeventgroupdocument)\n- [Group lifecycle & bot backfill](#group-lifecycle--bot-backfill)\n- [Spin flow, idempotency, and rollback](#spin-flow-idempotency-and-rollback)\n- [Resource shapes](#resource-shapes)\n\n---\n\n## Config: CoopEventDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CoopEventDefinitions>(\"CoopEvent\")`.\n\n```ts\ninterface CoopEventDefinitions {\n Chains?: Record<string, CoopEventChainDefinition>; // key = CoopChainID\n}\n```\n\n---\n\n## CoopEventChainDefinition\n\nA chain schedules an ordered sequence of events (`Events`), one live at a\ntime, using the SDK-wide `ScheduleSpec` (the same container Leaderboard,\nTimedEvent, Season, and TimedBoost use). Coop chains always run in\n`Schedule.Mode === \"Chained\"`.\n\n```ts\ninterface CoopEventChainDefinition {\n CoopChainID?: string;\n DisplayName?: string;\n Schedule?: ScheduleSpec; // Mode = \"Chained\"; Chain.AnchorUtc/MaxCycles/PauseBetweenPhasesSec/PauseBetweenCyclesSec\n Events?: CoopEventDefinition[]; // sort by Order ASC — this is the phase list\n Gate?: SegmentGate; // audience gate; null = available to everyone\n}\n```\n\n`Gate` (Core/Segment `SegmentGate`) is checked by the backend on\n`GetActiveEvent` and `JoinOrCreateGroup` — a player failing the gate gets\n`\"This coop event is not available for you.\"` on both calls; the chain simply\ndoesn't resolve to an active event for them. It does **not** block\n`getDefinitions()`, `getGroupState()`, `spin()`, or claim calls — those operate\non a `GroupID`/`CoopChainID` the player already has, not on chain-level\ndiscovery.\n\n`Schedule.IsActive === false`, or a chain with no `Events`, means\n`ComputeCoopEventWindow` returns nothing and every chain-scoped call\n(`GetActiveEvent`, `JoinOrCreateGroup`, `Spin`) fails with `\"No active coop\nevent in this chain.\"` (`CoopEvent.cs` lines 122, 261, 458). Pauses between\nphases/cycles (`PauseBetweenPhasesSec` / `PauseBetweenCyclesSec`) resolve to\nthe same window shape with `IsInPause = true`, which the backend treats\nidentically to \"no active event.\"\n\n---\n\n## CoopEventDefinition (one event in a chain)\n\n```ts\ninterface CoopEventDefinition {\n CoopEventID?: string; // e.g. \"coop_fairytale_partners_1\"\n Order?: number; // position in the chain (0, 1, 2, ...)\n DurationSec?: number; // default 518_400 (6 days); typical range 432,000–604,800 (5–7 days)\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n EventType?: \"BuildObjects\" | \"BossAttack\"; // default \"BuildObjects\"; BossAttack is reserved/unimplemented\n PartnerCount?: number; // default 4 — partners excluding the initiator\n MatchmakingTimeoutMinutes?: number; // default 5\n MemberGracePeriodMinutes?: number; // default 30\n BuildObjects?: CoopBuildObjectsDefinition; // populated only when EventType = \"BuildObjects\"\n GrandPrize?: ResourceGrant; // granted to every member once all objects complete\n}\n```\n\n**Group size is `1 + PartnerCount`.** With the default `PartnerCount = 4` that\nis 5 members total (1 initiator + 4 partners), matching `Objects` 0..4 if\n`BuildObjects.Objects` has 5 entries. `EventType` currently only really\nsupports `\"BuildObjects\"` — `spin` rejects any other type with `\"Spin is only\navailable for BuildObjects events.\"` (`CoopEvent.cs:463`); `BossAttack` exists\nin the enum (`CoopEventType.cs`, `CoopEventDefinitions.cs:41`) but has no\nimplemented mechanic yet (\"реализация позже\" / \"implementation later\" per the\nsource comment).\n\n---\n\n## CoopBuildObjectsDefinition + spinner formula\n\n```ts\ninterface CoopBuildObjectsDefinition {\n Objects?: CoopObjectDefinition[]; // one per partner slot; index = 0..(1+PartnerCount-1)\n SpinCost?: ResourceConsume; // charged per spin() call\n SpinnerTable?: CoopSpinnerSector[]; // weighted probability table\n}\n\ninterface CoopObjectDefinition {\n Index?: number; // 0..N-1, matches CoopPartnerObjectState.Index / CoopBuildObjectsMemberState.ObjectIndex\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n MaxProgress?: number; // default 100 — object completes at CurrentProgress >= MaxProgress\n CompletionReward?: ResourceGrant; // paid to the object's owner via claimObjectReward\n}\n\ninterface CoopSpinnerSector {\n DisplayName?: string; // e.g. \"Small\", \"Medium\", \"Big\", \"Jackpot\"\n AssetPaths?: Record<string, string>;\n Weight?: number; // arbitrary scale, does not need to sum to 100\n MinProgress?: number;\n MaxProgress?: number;\n}\n```\n\n**Sector selection formula** (`CoopEventHelpers.PickSpinnerSector`,\n`CoopEventHelpers.cs:200-213`): `P(sector) = sector.Weight / sum(all\nsector.Weight in SpinnerTable)`. Example: weights `[40, 35, 20, 5]` → 40%,\n35%, 20%, 5%. Selection uses `SecureRandom` over the cumulative-weight range,\nso it's a genuine server-side roll — never simulate the roll client-side for\nanything but a cosmetic pre-spin animation.\n\n**Progress-per-spin formula**: once a sector is chosen, the actual progress\nawarded is `SecureRandom.Next(sector.MinProgress, sector.MaxProgress + 1)` —\na uniform-random integer in the inclusive range `[MinProgress, MaxProgress]`\n(`CoopEvent.cs:526`). If the tentative new progress (`CurrentProgress +\ndelta`) would reach or exceed `MaxProgress`, the object clamps to exactly\n`MaxProgress` (no overshoot) and is marked `IsCompleted` (`CoopEvent.cs:564-566`).\n\n**Empty-`Objects` fallback**: if a `CoopEventDefinition.BuildObjects.Objects`\nlist is empty when a new group is created, the backend synthesizes\n`1 + PartnerCount` objects with `MaxProgress = 100` and no completion reward\n(`CoopEvent.cs:1255-1268`) rather than failing — so a misconfigured title still\nproduces a playable (if reward-less) event. Don't rely on this — always\nconfigure `Objects` explicitly if you want completion rewards.\n\n**Spin cost accounting**: `SpinCost` is a `ResourceConsume`; the backend sums\n`Standard.EventTokens[].Amount` + `Standard.Entries[].Amount` into a single\n`spinTokenAmount` purely for the group document's `TokensSpent` counter\n(`CoopEvent.cs:528-532`) — the actual debit against the player's balance goes\nthrough `ResourceService.ApplyResourceOperationAtomicAsync` using the full\n`ResourceConsume` (including any `PremiumDiscounts`), so the amount reflected\nin `Resources.Consume` on the response can be lower than the raw config sum if\nthe player has a discount-granting premium tier. `spin()` rejects with\n`\"SpinCost not configured.\"` if `SpinCost.Standard` is null, and with\n`\"SpinnerTable not configured.\"` if the table is empty.\n\n---\n\n## ActiveCoopEventInfo (getActiveEvent)\n\nThe **typed** SDK model (`zActiveCoopEventInfo`) is intentionally thin:\n\n```ts\ninterface ActiveCoopEventInfo {\n CoopChainID: string;\n // + untyped passthrough fields, see below\n}\n```\n\nThe **actual backend response** (`ActiveCoopEventInfo` C# class,\n`CoopEvent.cs:1364-1373`) carries more:\n\n```ts\n// present at runtime, NOT in the TS type — cast explicitly if you need them\ninterface ActiveCoopEventInfoWire extends ActiveCoopEventInfo {\n CycleIndex: number; // which cycle of the chain this is\n EventOrder: number; // Order of the live CoopEventDefinition\n EventDef: CoopEventDefinition; // the full live event config (spin cost, spinner, objects, grand prize)\n ComputedStartUtc: string; // ISO — when this event's window started\n ComputedEndUtc: string; // ISO — when this event's window ends\n SecondsRemaining: number; // computed server-side at response time\n}\n```\n\nSince every schema in this SDK keeps `.passthrough()`, these fields survive\nparsing and are readable off the object — just not type-checked. Prefer\ncasting the response (`active.data as ActiveCoopEventInfoWire`) over widening\nthe whole module's types yourself.\n\n---\n\n## Player state: UserCoopEventState\n\nReturned inside `CoopUserStateResponse.UserState` by `getUserState()`, and\ncached at `client.data.user.state?.CoopEvent`.\n\n```ts\ninterface UserCoopEventState {\n ActiveGroupID?: string | null; // group the player is currently in, or null\n ActiveCoopEventID?: string | null; // which CoopEventID that group belongs to\n MyObjectIndex?: number; // which BuildObjects object is theirs; -1 = unresolved/none\n LastSpinAtUtc?: string; // ISO — server bookkeeping, not itself a rate limit you should read\n History?: CoopEventHistoryEntry[]; // most recent 5 finished cycles (FIFO)\n}\n\ninterface CoopEventHistoryEntry {\n GroupID?: string;\n CoopEventID?: string;\n FinalStatus?: string; // \"Completed\" | \"Failed\" (CoopGroupStatus at finish time)\n GrandPrizeReceived?: boolean;\n FinishedAtUtc?: string; // ISO\n}\n```\n\n`getUserState()` self-heals stale pointers: if the player's `ActiveGroupID`\npoints at a group that has become terminal (`Failed`, `Expired`, or past its\n`ExpiresAtUtc` without completing — see\n[Group lifecycle](#group-lifecycle--bot-backfill)), the backend clears\n`ActiveGroupID`/`ActiveCoopEventID` to `null` and `MyObjectIndex` to `-1`\nserver-side before returning, in the same call (`CoopEvent.cs:171-177`) — you\ndon't need to detect and clear this yourself.\n\n---\n\n## Group state: CoopEventGroupDocument\n\nReturned as `Group` by `getGroupState()`, `joinOrCreateGroup()`, and inside\n`CoopUserStateResponse.ActiveGroup`. This document is **shared** — every\nmember's calls read and write the same record (collection `CoopGroups`,\nkeyed by `GroupID`, with MongoDB optimistic locking on `Version`).\n\n```ts\ninterface CoopGroupDocument {\n GroupID?: string;\n TitleID?: string;\n CoopChainID?: string;\n CoopEventID?: string;\n CycleIndex?: number; // which chain cycle this group belongs to\n Members?: CoopGroupMember[];\n BuildObjectsState?: { Objects?: CoopPartnerObjectState[] };\n Status?: \"Forming\" | \"Active\" | \"Completed\" | \"Failed\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string; // group fails automatically once now >= this\n Version?: number; // optimistic-lock counter; increments on every mutation\n}\n\ninterface CoopGroupMember {\n UserID?: string;\n PublicData?: UserPublicDataModel; // snapshot taken at join time\n BuildObjectsProgress?: {\n ObjectIndex?: number;\n ObjectCompletionRewardClaimed?: boolean;\n };\n IsBot?: boolean;\n SpinsCount?: number;\n TokensSpent?: number; // sum of SpinCost token amounts, config-side (see spin cost accounting above)\n MemberStatus?: \"Active\" | \"Left\" | \"Replaced\";\n GrandPrizeClaimed?: boolean;\n JoinedAtUtc?: string;\n LeftAtUtc?: string; // set when MemberStatus becomes \"Left\"; row is never removed\n}\n\ninterface CoopPartnerObjectState {\n Index?: number; // matches CoopObjectDefinition.Index / member's ObjectIndex\n OwnerUserID?: string; // set once the owning member is known (join-time or bot-fill time)\n CurrentProgress?: number;\n MaxProgress?: number; // copied from config at group-creation time\n IsCompleted?: boolean;\n}\n```\n\n---\n\n## Group lifecycle & bot backfill\n\n`CoopGroupStatus` transitions (all server-driven, never set by the client):\n\n- **Forming** → initial state when a group is created; still recruiting.\n- **Forming → Active**: either matchmaking fills every slot\n (`1 + PartnerCount` members), or `MatchmakingTimeoutMinutes` elapses and the\n backend lazily backfills remaining slots with bots on the next read\n (`TryFillWithBotsIfNeeded`, `CoopEvent.cs:1029-1100`) — triggered from\n `getGroupState`, `joinOrCreateGroup`, and (indirectly) `getUserState`. If a\n group is `Forming` and the last active human member leaves, it flips\n straight to `Failed` instead (`CoopEvent.cs:990-994`).\n- **Active → Completed**: the moment every object's `IsCompleted` becomes\n true (checked right after each spin commits, and after each lazy\n bot-progress simulation). Grand Prize is claimable once `Status ===\n\"Completed\"`.\n- **Active/Forming → Failed**: `now >= ExpiresAtUtc` before all objects\n finish. Checked lazily on `getGroupState` and `spin`. No Grand Prize is ever\n granted for a `Failed` group.\n- **→ Expired**: a later archival state (grace period after\n Completed/Failed) — `IsGroupTerminal` treats `Failed`, `Expired`, and \"past\n `ExpiresAtUtc` while not `Completed`\" as equivalent terminal states for the\n self-heal check in `GetUserState`.\n\n**Bot simulation** (`SimulateBotProgressAsync`, `CoopEvent.cs:1111-1186`) runs\non every `getGroupState` / `joinOrCreateGroup` call while the group is\n`Active` and has bot members. It's a deterministic linear-interpolation model,\nnot a real spin loop:\n\n- `progress = clamp(elapsedSec / totalSec, 0, 1)` where `totalSec =\nExpiresAtUtc - CreatedAtUtc` and `elapsedSec = now - CreatedAtUtc`.\n- Each bot gets a fixed per-bot `efficiency = 0.60 + rng.NextDouble() * 0.30`\n (uniformly 60%–90%), seeded deterministically from `HashSeed(GroupID +\nBotUserID)` so every player's read of the same group sees the same bot\n trajectory.\n- `targetProgress = floor(MaxProgress * efficiency * progress)`; the bot's\n `CurrentProgress` is advanced toward (never past) that target and clamped to\n `MaxProgress`, marking `IsCompleted` on reaching it.\n\nThis means a bot's bar can visibly jump when you reopen the group screen\nafter time has passed — that's expected, not a bug to work around.\n\n---\n\n## Spin flow, idempotency, and rollback\n\n`spin()` runs as two phases against different stores, in this order:\n\n1. **Group document** (`coop_groups`): an optimistic-lock patch adds the\n rolled progress to the caller's object, bumps `Version`, and increments\n `Members[i].SpinsCount`/`TokensSpent`. Retried up to 3 times\n (`SpinMaxRetries`) on a concurrent-write conflict before failing with\n `\"Spin commit failed (concurrent update). Please retry.\"`.\n2. **Resource ledger**: only after phase 1 commits, the token/currency cost is\n charged via `ResourceService.ApplyResourceOperationAtomicAsync` under a\n `reason` of `CoopSpin:<idempotencyKey>` (the idempotency key is derived\n from the request's `RelatedEntityID`, salted with the chain+group). If the\n charge fails, the backend best-effort **rolls back** the group progress it\n just committed in phase 1 (decrements `CurrentProgress`/`SpinsCount`/\n `TokensSpent`) and returns `\"Spin failed: <reason>\"`.\n\nBecause the idempotency key is keyed off `RelatedEntityID`, and the SDK's\n`buildAuthedBaseRequest()` mints a fresh UUID for every call, retrying the\nexact same client-side call object (same `RelatedEntityID`) is safe — a\nduplicate charge is suppressed as an `IdempotentReplay` — but issuing a\n**new** `spin()` call (fresh `RelatedEntityID`, e.g. from a second button\nclick) is a genuinely new operation and will charge again. This is the same\n\"idempotency protects retries, not double-submits\" rule as every other module\nin the SDK.\n\n`claimObjectReward` and `claimGrandPrize` follow the same two-phase,\nrollback-on-failure pattern against their own flags\n(`ObjectCompletionRewardClaimed`, `GrandPrizeClaimed`) instead of a progress\ndelta.\n\n---\n\n## Resource shapes\n\n`SpinCost` (`ResourceConsume`), `CompletionReward` and `GrandPrize`\n(`ResourceGrant`), and every response's `Resources` (`ResourceOperation`) use\nthe SDK-wide shared shapes from `_shared/ResourceModels` — the same types\n`Store`, `Character`, `Craft`, and every other resource-touching module use:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[]; // currencies/items: { Type, CurrencyID?, Amount?, CatalogID?, ItemID? }\n EventTokens?: EventTokenOperation[]; // { Address: { Type, EntityID }, Amount, Source? }\n}\ninterface ResourceGrant {\n Standard?: ResourceBundle;\n PremiumBonuses?: unknown[];\n PremiumTiers?: PremiumTierBundle[]; // extra bundles unlocked by MinPremiumTier/RequiredPremiumID\n}\ninterface ResourceConsume {\n Standard?: ResourceBundle;\n PremiumDiscounts?: unknown[];\n PremiumTiers?: PremiumTierBundle[];\n}\ninterface ResourceOperation {\n Grant?: ResourceGrant;\n Consume?: ResourceConsume;\n}\n```\n\nDon't reimplement premium-discount or tier-bundle resolution client-side —\nthe backend resolves the player's best applicable tier/discount and returns\nthe _actually applied_ amounts in the response's `Resources`, which the\nservice already applies to the cache for you.\n"
|
|
8
|
+
"content": "# Coop event data model — reference\n\nFull shape of the config (Definitions), the active-event lookup, player state,\nand the shared group document, plus the spinner-weight, progress-roll, and\nbot-simulation formulas the backend actually runs. All of these are **strictly\ntyped in the SDK** where noted below — `CoopEventDefinitions` and its nested\nblocks are exported from `@idosgames/core` with `.passthrough()` schemas, so a\nfield the backend adds later still round-trips even though it may not appear\nin the TS type. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CoopEventDefinitions](#config-coopeventdefinitions) — what `getDefinitions()` returns\n- [CoopEventChainDefinition](#coopeventchaindefinition)\n- [CoopEventDefinition](#coopeventdefinition-one-event-in-a-chain)\n- [CoopBuildObjectsDefinition + spinner formula](#coopbuildobjectsdefinition--spinner-formula)\n- [ActiveCoopEventInfo (getActiveEvent)](#activecoopeventinfo-getactiveevent)\n- [Player state: UserCoopEventState](#player-state-usercoopeventstate)\n- [Group state: CoopEventGroupDocument](#group-state-coopeventgroupdocument)\n- [Group lifecycle & bot backfill](#group-lifecycle--bot-backfill)\n- [Spin flow, idempotency, and rollback](#spin-flow-idempotency-and-rollback)\n- [Resource shapes](#resource-shapes)\n\n---\n\n## Config: CoopEventDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CoopEventDefinitions>(\"CoopEvent\")`.\n\n```ts\ninterface CoopEventDefinitions {\n Chains?: Record<string, CoopEventChainDefinition>; // key = CoopChainID\n}\n```\n\n---\n\n## CoopEventChainDefinition\n\nA chain schedules an ordered sequence of events (`Events`), one live at a\ntime, using the SDK-wide `ScheduleSpec` (the same container Leaderboard,\nTimedEvent, Season, and TimedBoost use). Coop chains always run in\n`Schedule.Mode === \"Chained\"`.\n\n```ts\ninterface CoopEventChainDefinition {\n CoopChainID?: string;\n DisplayName?: string;\n Schedule?: ScheduleSpec; // Mode = \"Chained\"; Chain.AnchorUtc/MaxCycles/PauseBetweenPhasesSec/PauseBetweenCyclesSec\n Events?: CoopEventDefinition[]; // sort by Order ASC — this is the phase list\n Gate?: SegmentGate; // audience gate; null = available to everyone\n}\n```\n\n`Gate` (Core/Segment `SegmentGate`) is checked by the backend on\n`GetActiveEvent` and `JoinOrCreateGroup` — a player failing the gate gets\n`\"This coop event is not available for you.\"` on both calls; the chain simply\ndoesn't resolve to an active event for them. It does **not** block\n`getDefinitions()`, `getGroupState()`, `spin()`, or claim calls — those operate\non a `GroupID`/`CoopChainID` the player already has, not on chain-level\ndiscovery.\n\n`Schedule.IsActive === false`, or a chain with no `Events`, means\n`ComputeCoopEventWindow` returns nothing and every chain-scoped call\n(`GetActiveEvent`, `JoinOrCreateGroup`, `Spin`) fails with `\"No active coop\nevent in this chain.\"` (`CoopEvent.cs` lines 122, 261, 458). Pauses between\nphases/cycles (`PauseBetweenPhasesSec` / `PauseBetweenCyclesSec`) resolve to\nthe same window shape with `IsInPause = true`, which the backend treats\nidentically to \"no active event.\"\n\n---\n\n## CoopEventDefinition (one event in a chain)\n\n```ts\ninterface CoopEventDefinition {\n CoopEventID?: string; // e.g. \"coop_fairytale_partners_1\"\n Order?: number; // position in the chain (0, 1, 2, ...)\n DurationSec?: number; // default 518_400 (6 days); typical range 432,000–604,800 (5–7 days)\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n EventType?: \"BuildObjects\" | \"BossAttack\"; // default \"BuildObjects\"; BossAttack is reserved/unimplemented\n PartnerCount?: number; // default 4 — partners excluding the initiator\n MatchmakingTimeoutMinutes?: number; // default 5\n MemberGracePeriodMinutes?: number; // default 30\n BuildObjects?: CoopBuildObjectsDefinition; // populated only when EventType = \"BuildObjects\"\n GrandPrize?: ResourceGrant; // granted to every member once all objects complete\n}\n```\n\n**Group size is `1 + PartnerCount`.** With the default `PartnerCount = 4` that\nis 5 members total (1 initiator + 4 partners), matching `Objects` 0..4 if\n`BuildObjects.Objects` has 5 entries. `EventType` currently only really\nsupports `\"BuildObjects\"` — `spin` rejects any other type with `\"Spin is only\navailable for BuildObjects events.\"` (`CoopEvent.cs:463`); `BossAttack` exists\nin the enum (`CoopEventType.cs`, `CoopEventDefinitions.cs:41`) but has no\nimplemented mechanic yet (\"реализация позже\" / \"implementation later\" per the\nsource comment).\n\n---\n\n## CoopBuildObjectsDefinition + spinner formula\n\n```ts\ninterface CoopBuildObjectsDefinition {\n Objects?: CoopObjectDefinition[]; // one per partner slot; index = 0..(1+PartnerCount-1)\n PriceOptions?: Record<string, PriceOption>; // ways to pay a spin; the selected one is charged per spin() call\n SpinnerTable?: CoopSpinnerSector[]; // weighted probability table\n}\n\ninterface CoopObjectDefinition {\n Index?: number; // 0..N-1, matches CoopPartnerObjectState.Index / CoopBuildObjectsMemberState.ObjectIndex\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n MaxProgress?: number; // default 100 — object completes at CurrentProgress >= MaxProgress\n CompletionReward?: ResourceGrant; // paid to the object's owner via claimObjectReward\n}\n\ninterface CoopSpinnerSector {\n DisplayName?: string; // e.g. \"Small\", \"Medium\", \"Big\", \"Jackpot\"\n AssetPaths?: Record<string, string>;\n Weight?: number; // arbitrary scale, does not need to sum to 100\n MinProgress?: number;\n MaxProgress?: number;\n}\n```\n\n**Sector selection formula** (`CoopEventHelpers.PickSpinnerSector`,\n`CoopEventHelpers.cs:200-213`): `P(sector) = sector.Weight / sum(all\nsector.Weight in SpinnerTable)`. Example: weights `[40, 35, 20, 5]` → 40%,\n35%, 20%, 5%. Selection uses `SecureRandom` over the cumulative-weight range,\nso it's a genuine server-side roll — never simulate the roll client-side for\nanything but a cosmetic pre-spin animation.\n\n**Progress-per-spin formula**: once a sector is chosen, the actual progress\nawarded is `SecureRandom.Next(sector.MinProgress, sector.MaxProgress + 1)` —\na uniform-random integer in the inclusive range `[MinProgress, MaxProgress]`\n(`CoopEvent.cs:526`). If the tentative new progress (`CurrentProgress +\ndelta`) would reach or exceed `MaxProgress`, the object clamps to exactly\n`MaxProgress` (no overshoot) and is marked `IsCompleted` (`CoopEvent.cs:564-566`).\n\n**Empty-`Objects` fallback**: if a `CoopEventDefinition.BuildObjects.Objects`\nlist is empty when a new group is created, the backend synthesizes\n`1 + PartnerCount` objects with `MaxProgress = 100` and no completion reward\n(`CoopEvent.cs:1255-1268`) rather than failing — so a misconfigured title still\nproduces a playable (if reward-less) event. Don't rely on this — always\nconfigure `Objects` explicitly if you want completion rewards.\n\n**Spin cost accounting**: the selected option's `Cost` is a `ResourceConsume`; the backend sums\n`Standard.EventTokens[].Amount` + `Standard.Entries[].Amount` into a single\n`spinTokenAmount` purely for the group document's `TokensSpent` counter\n(`CoopEvent.cs:528-532`) — the actual debit against the player's balance goes\nthrough `ResourceService.ApplyResourceOperationAtomicAsync` using the full\n`ResourceConsume` (including any `PremiumDiscounts`), so the amount reflected\nin `Resources.Consume` on the response can be lower than the raw config sum if\nthe player has a discount-granting premium tier. `spin()` rejects with\n`\"Spin cost is empty.\"` if the selected option carries nothing to charge, and with\n`\"SpinnerTable not configured.\"` if the table is empty.\n\n`PriceOptions` is the platform-wide price shape: the dictionary key is the\n`OptionID` and `spin()` takes it as its fourth argument; omitting it takes the\nfirst option available on the caller's platform. ⚠ **A spin can never be paid in\na store**: only the spins that actually happened are charged (the run stops at the\nspin that completes the object), and a receipt cannot pay for a fraction of\nitself — a `Purchase` entry here is rejected.\n\n---\n\n## ActiveCoopEventInfo (getActiveEvent)\n\nThe **typed** SDK model (`zActiveCoopEventInfo`) is intentionally thin:\n\n```ts\ninterface ActiveCoopEventInfo {\n CoopChainID: string;\n // + untyped passthrough fields, see below\n}\n```\n\nThe **actual backend response** (`ActiveCoopEventInfo` C# class,\n`CoopEvent.cs:1364-1373`) carries more:\n\n```ts\n// present at runtime, NOT in the TS type — cast explicitly if you need them\ninterface ActiveCoopEventInfoWire extends ActiveCoopEventInfo {\n CycleIndex: number; // which cycle of the chain this is\n EventOrder: number; // Order of the live CoopEventDefinition\n EventDef: CoopEventDefinition; // the full live event config (spin cost, spinner, objects, grand prize)\n ComputedStartUtc: string; // ISO — when this event's window started\n ComputedEndUtc: string; // ISO — when this event's window ends\n SecondsRemaining: number; // computed server-side at response time\n}\n```\n\nSince every schema in this SDK keeps `.passthrough()`, these fields survive\nparsing and are readable off the object — just not type-checked. Prefer\ncasting the response (`active.data as ActiveCoopEventInfoWire`) over widening\nthe whole module's types yourself.\n\n---\n\n## Player state: UserCoopEventState\n\nReturned inside `CoopUserStateResponse.UserState` by `getUserState()`, and\ncached at `client.data.user.state?.CoopEvent`.\n\n```ts\ninterface UserCoopEventState {\n ActiveGroupID?: string | null; // group the player is currently in, or null\n ActiveCoopEventID?: string | null; // which CoopEventID that group belongs to\n MyObjectIndex?: number; // which BuildObjects object is theirs; -1 = unresolved/none\n LastSpinAtUtc?: string; // ISO — server bookkeeping, not itself a rate limit you should read\n History?: CoopEventHistoryEntry[]; // most recent 5 finished cycles (FIFO)\n}\n\ninterface CoopEventHistoryEntry {\n GroupID?: string;\n CoopEventID?: string;\n FinalStatus?: string; // \"Completed\" | \"Failed\" (CoopGroupStatus at finish time)\n GrandPrizeReceived?: boolean;\n FinishedAtUtc?: string; // ISO\n}\n```\n\n`getUserState()` self-heals stale pointers: if the player's `ActiveGroupID`\npoints at a group that has become terminal (`Failed`, `Expired`, or past its\n`ExpiresAtUtc` without completing — see\n[Group lifecycle](#group-lifecycle--bot-backfill)), the backend clears\n`ActiveGroupID`/`ActiveCoopEventID` to `null` and `MyObjectIndex` to `-1`\nserver-side before returning, in the same call (`CoopEvent.cs:171-177`) — you\ndon't need to detect and clear this yourself.\n\n---\n\n## Group state: CoopEventGroupDocument\n\nReturned as `Group` by `getGroupState()`, `joinOrCreateGroup()`, and inside\n`CoopUserStateResponse.ActiveGroup`. This document is **shared** — every\nmember's calls read and write the same record (collection `CoopGroups`,\nkeyed by `GroupID`, with MongoDB optimistic locking on `Version`).\n\n```ts\ninterface CoopGroupDocument {\n GroupID?: string;\n TitleID?: string;\n CoopChainID?: string;\n CoopEventID?: string;\n CycleIndex?: number; // which chain cycle this group belongs to\n Members?: CoopGroupMember[];\n BuildObjectsState?: { Objects?: CoopPartnerObjectState[] };\n Status?: \"Forming\" | \"Active\" | \"Completed\" | \"Failed\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string; // group fails automatically once now >= this\n Version?: number; // optimistic-lock counter; increments on every mutation\n}\n\ninterface CoopGroupMember {\n UserID?: string;\n PublicData?: UserPublicDataModel; // snapshot taken at join time\n BuildObjectsProgress?: {\n ObjectIndex?: number;\n ObjectCompletionRewardClaimed?: boolean;\n };\n IsBot?: boolean;\n SpinsCount?: number;\n TokensSpent?: number; // sum of the charged option's token amounts, config-side (see spin cost accounting above)\n MemberStatus?: \"Active\" | \"Left\" | \"Replaced\";\n GrandPrizeClaimed?: boolean;\n JoinedAtUtc?: string;\n LeftAtUtc?: string; // set when MemberStatus becomes \"Left\"; row is never removed\n}\n\ninterface CoopPartnerObjectState {\n Index?: number; // matches CoopObjectDefinition.Index / member's ObjectIndex\n OwnerUserID?: string; // set once the owning member is known (join-time or bot-fill time)\n CurrentProgress?: number;\n MaxProgress?: number; // copied from config at group-creation time\n IsCompleted?: boolean;\n}\n```\n\n---\n\n## Group lifecycle & bot backfill\n\n`CoopGroupStatus` transitions (all server-driven, never set by the client):\n\n- **Forming** → initial state when a group is created; still recruiting.\n- **Forming → Active**: either matchmaking fills every slot\n (`1 + PartnerCount` members), or `MatchmakingTimeoutMinutes` elapses and the\n backend lazily backfills remaining slots with bots on the next read\n (`TryFillWithBotsIfNeeded`, `CoopEvent.cs:1029-1100`) — triggered from\n `getGroupState`, `joinOrCreateGroup`, and (indirectly) `getUserState`. If a\n group is `Forming` and the last active human member leaves, it flips\n straight to `Failed` instead (`CoopEvent.cs:990-994`).\n- **Active → Completed**: the moment every object's `IsCompleted` becomes\n true (checked right after each spin commits, and after each lazy\n bot-progress simulation). Grand Prize is claimable once `Status ===\n\"Completed\"`.\n- **Active/Forming → Failed**: `now >= ExpiresAtUtc` before all objects\n finish. Checked lazily on `getGroupState` and `spin`. No Grand Prize is ever\n granted for a `Failed` group.\n- **→ Expired**: a later archival state (grace period after\n Completed/Failed) — `IsGroupTerminal` treats `Failed`, `Expired`, and \"past\n `ExpiresAtUtc` while not `Completed`\" as equivalent terminal states for the\n self-heal check in `GetUserState`.\n\n**Bot simulation** (`SimulateBotProgressAsync`, `CoopEvent.cs:1111-1186`) runs\non every `getGroupState` / `joinOrCreateGroup` call while the group is\n`Active` and has bot members. It's a deterministic linear-interpolation model,\nnot a real spin loop:\n\n- `progress = clamp(elapsedSec / totalSec, 0, 1)` where `totalSec =\nExpiresAtUtc - CreatedAtUtc` and `elapsedSec = now - CreatedAtUtc`.\n- Each bot gets a fixed per-bot `efficiency = 0.60 + rng.NextDouble() * 0.30`\n (uniformly 60%–90%), seeded deterministically from `HashSeed(GroupID +\nBotUserID)` so every player's read of the same group sees the same bot\n trajectory.\n- `targetProgress = floor(MaxProgress * efficiency * progress)`; the bot's\n `CurrentProgress` is advanced toward (never past) that target and clamped to\n `MaxProgress`, marking `IsCompleted` on reaching it.\n\nThis means a bot's bar can visibly jump when you reopen the group screen\nafter time has passed — that's expected, not a bug to work around.\n\n---\n\n## Spin flow, idempotency, and rollback\n\n`spin()` runs as two phases against different stores, in this order:\n\n1. **Group document** (`coop_groups`): an optimistic-lock patch adds the\n rolled progress to the caller's object, bumps `Version`, and increments\n `Members[i].SpinsCount`/`TokensSpent`. Retried up to 3 times\n (`SpinMaxRetries`) on a concurrent-write conflict before failing with\n `\"Spin commit failed (concurrent update). Please retry.\"`.\n2. **Resource ledger**: only after phase 1 commits, the token/currency cost is\n charged via `ResourceService.ApplyResourceOperationAtomicAsync` under a\n `reason` of `CoopSpin:<idempotencyKey>` (the idempotency key is derived\n from the request's `RelatedEntityID`, salted with the chain+group). If the\n charge fails, the backend best-effort **rolls back** the group progress it\n just committed in phase 1 (decrements `CurrentProgress`/`SpinsCount`/\n `TokensSpent`) and returns `\"Spin failed: <reason>\"`.\n\nBecause the idempotency key is keyed off `RelatedEntityID`, and the SDK's\n`buildAuthedBaseRequest()` mints a fresh UUID for every call, retrying the\nexact same client-side call object (same `RelatedEntityID`) is safe — a\nduplicate charge is suppressed as an `IdempotentReplay` — but issuing a\n**new** `spin()` call (fresh `RelatedEntityID`, e.g. from a second button\nclick) is a genuinely new operation and will charge again. This is the same\n\"idempotency protects retries, not double-submits\" rule as every other module\nin the SDK.\n\n`claimObjectReward` and `claimGrandPrize` follow the same two-phase,\nrollback-on-failure pattern against their own flags\n(`ObjectCompletionRewardClaimed`, `GrandPrizeClaimed`) instead of a progress\ndelta.\n\n---\n\n## Resource shapes\n\n`PriceOptions[].Cost` (`ResourceConsume`), `CompletionReward` and `GrandPrize`\n(`ResourceGrant`), and every response's `Resources` (`ResourceOperation`) use\nthe SDK-wide shared shapes from `_shared/ResourceModels` — the same types\n`Store`, `Character`, `Craft`, and every other resource-touching module use:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[]; // currencies/items: { Type, CurrencyID?, Amount?, CatalogID?, ItemID? }\n EventTokens?: EventTokenOperation[]; // { Address: { Type, EntityID }, Amount, Source? }\n}\ninterface ResourceGrant {\n Standard?: ResourceBundle;\n PremiumBonuses?: unknown[];\n PremiumTiers?: PremiumTierBundle[]; // extra bundles unlocked by MinPremiumTier/RequiredPremiumID\n}\ninterface ResourceConsume {\n Standard?: ResourceBundle;\n PremiumDiscounts?: unknown[];\n PremiumTiers?: PremiumTierBundle[];\n}\ninterface ResourceOperation {\n Grant?: ResourceGrant;\n Consume?: ResourceConsume;\n}\n```\n\nDon't reimplement premium-discount or tier-bundle resolution client-side —\nthe backend resolves the player's best applicable tier/discount and returns\nthe _actually applied_ amounts in the response's `Resources`, which the\nservice already applies to the cache for you.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "craft-system",
|
|
3
3
|
"description": "Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.craft (CraftService): load craft recipe definitions and execute a craft that burns input item instances (trade-up by rarity or trade-up by collection) to produce a rolled output item. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage UIs, or otherwise touches client.craft, CraftService, CraftDefinitions, CraftDefinition, or CraftResponse — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?)` | Burn `inputItemIDs`, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are item _instance_ ids, not catalog/definition ids.** The\n server checks each instance's underlying `ItemID` against the recipe's\n allowed-input set (by `InputRarityID`, and by `CollectionID` too for\n `TradeUpCollection`) and that the player actually holds enough of that item\n in total (equipped instances don't count — see Gotchas).\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<optionID, { OptionID, RequiredResources }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the instance ids actually consumed for this iteration\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.RequiredResources.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Craft\" burns items twice. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
|
|
4
|
+
"content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?)` | Burn `inputItemIDs`, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are item _instance_ ids, not catalog/definition ids.** The\n server checks each instance's underlying `ItemID` against the recipe's\n allowed-input set (by `InputRarityID`, and by `CollectionID` too for\n `TradeUpCollection`) and that the player actually holds enough of that item\n in total (equipped instances don't count — see Gotchas).\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<OptionID, { OptionID, Name, Cost, AllowedPlatforms }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the instance ids actually consumed for this iteration\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.Cost.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Craft\" burns items twice. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n}\n```\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## CraftPriceOption\n\nOne payment option for a recipe.\n\n```ts\ninterface CraftPriceOption {\n OptionID?: string;\n RequiredResources?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n}\n```\n\n`RequiredResources` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option\n with `RequiredResources = new ResourceConsume()` (no cost) is used, no\n input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first**\n entry of the map is used (`craftConfig.PriceOptions.First()` — .NET\n dictionary enumeration order, effectively insertion order; don't rely on\n this being a semantically \"default\" or \"cheapest\" option).\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `UsdCent` are rejected outright → `\"Invalid price\noption: UsdCent is not supported as in-game price.\"`\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the instance ids consumed in this specific iteration\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n}\n```\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
|
|
8
|
+
"content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n}\n```\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## PriceOption\n\nOne payment option for a recipe — the platform-wide price shape, identical in\nevery module (see the `checkout-system` skill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string;\n Cost?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A craft can never be paid in a store.** The price is per craft and multiplied\nby `count`, while a receipt pays for exactly one SKU — there is no \"one and a half\nreceipts\" for a batch of one and a half crafts. A `Purchase` entry here is rejected\nwith `\"Craft cannot be paid in a store.\"`\n\n`Cost` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option with\n an empty cost is used, no input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first option\n available on the caller's platform**, ordered by `OptionID`. The order is\n explicit (not dictionary order) so the default is deterministic — but it is\n still \"first\", not \"cheapest\".\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `Purchase` are rejected outright → `\"Craft cannot be\npaid in a store.\"` (see the PriceOption section above)\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the instance ids consumed in this specific iteration\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n}\n```\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|