@idosgames/mcp 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +19 -15
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/character-system.json +2 -2
- package/registry/skills/checkout-system.json +6 -0
- package/registry/skills/collection-system.json +2 -2
- package/registry/skills/coop-event-system.json +2 -2
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/deal-offer-system.json +2 -2
- package/registry/skills/game-loop-system.json +1 -1
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/localization-system.json +1 -1
- package/registry/skills/lootbox-system.json +2 -2
- package/registry/skills/marketplace-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +2 -2
- package/registry/skills/referral-system.json +1 -1
- package/registry/skills/season-system.json +1 -1
- package/registry/skills/store-system.json +2 -2
- package/registry/skills/timed-boost-system.json +2 -2
- package/registry/skills/tutorial-system.json +1 -1
|
@@ -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
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Currency data model — reference\n\nFull shape of the `CurrencyDefinitions` config, and — the canonical\ndocumentation for the whole SDK — the shared `ResourceConsume` /\n`ResourceGrant` / `ResourceOperation` / `ResourceEntry` cost-and-reward\nprimitives. All types are **strictly typed** and exported from\n`@idosgames/core`; every object schema keeps `.passthrough()`, so a field the\nbackend adds later still round-trips instead of being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CurrencyDefinitions](#config-currencydefinitions)\n- [VirtualCurrencyDefinition](#virtualcurrencydefinition)\n- [CryptoCurrencyDefinition](#cryptocurrencydefinition)\n- [Shared conversion config](#shared-conversion-config)\n- [Backend conversion formulas](#backend-conversion-formulas) — transcribed from `ConversionService.cs`\n- [The shared resource primitives](#the-shared-resource-primitives) — canonical home\n - [ResourceEntry](#resourceentry)\n - [ResourceBundle](#resourcebundle)\n - [PremiumTierBundle](#premiumtierbundle)\n - [ResourceGrant](#resourcegrant)\n - [ResourceConsume](#resourceconsume)\n - [ResourceOperation](#resourceoperation)\n - [EventTokenOperation / EventTokenAddress](#eventtokenoperation--eventtokenaddress)\n - [ResourceDualPartyResult / ResourceTransferResult](#resourcedualpartyresult--resourcetransferresult)\n- [How the SDK applies a ResourceOperation](#how-the-sdk-applies-a-resourceoperation)\n\n---\n\n## Config: CurrencyDefinitions\n\n```ts\ninterface CurrencyDefinitions {\n VirtualCurrencies?: Record<string, VirtualCurrencyDefinition> | null;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition> | null;\n}\n```\n\nKey in both maps is the `CurrencyID`. A currency is \"known\" iff it has an\nentry in one of these maps under its `CurrencyType` (`Virtual` or `Crypto`).\n\n---\n\n## VirtualCurrencyDefinition\n\n```ts\ninterface VirtualCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>; // \"icon\", ...\n Economy?: {\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string; // ISO timestamp\n InitialDeposit?: number; // starting balance for new players\n MinBalance?: number;\n MaxBalance?: number;\n DailyEarnLimit?: number;\n DailySpendLimit?: number;\n };\n Recharge?: {\n // energy-style auto-regen\n Rate?: number;\n Max?: number;\n Period?: number;\n };\n Conversion?: CurrencyConversion; // see below\n Permissions?: {\n IsTradable?: boolean;\n IsPurchasable?: boolean;\n IsRefundable?: boolean;\n };\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n```\n\n`Status` governs whether the currency is usable/visible; `\"Maintenance\"` /\n`\"Deprecated\"` currencies typically reject conversions server-side even if\n`Conversion.Enabled` is true.\n\n---\n\n## CryptoCurrencyDefinition\n\n```ts\ninterface CryptoCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n DisplayDecimals?: number; // UI rounding, not wire precision\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string;\n DeveloperDepositSharePercent?: string; // decimal string; see blockchain-system\n Networks?: CryptoNetworkBinding[]; // per-chain bindings\n Limits?: {\n DailyWithdrawUsd?: string;\n MonthlyWithdrawUsd?: string;\n KycRequiredAboveUsd?: string;\n };\n Permissions?: {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n SpendableInGame?: boolean;\n ConvertibleToVirtual?: boolean; // gates cryptoConvert eligibility\n };\n Conversion?: CurrencyConversion;\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n\ninterface CryptoNetworkBinding {\n NetworkID: string;\n ContractAddress?: string;\n Decimals?: number; // on-chain token decimals\n MinDeposit?: string;\n MinWithdraw?: string;\n WithdrawFee?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nDeposit/withdrawal flows (and `Networks`/`Limits` enforcement for those flows)\nbelong to the Blockchain module — see the blockchain-system skill.\n`Permissions.ConvertibleToVirtual` is the flag most relevant here: it's what\nlets a crypto balance participate in `cryptoConvert`.\n\n---\n\n## Shared conversion config\n\nBoth currency kinds reuse the same `CurrencyConversion` shape for their\n`Conversion` field:\n\n```ts\ninterface CurrencyConversion {\n Enabled?: boolean;\n RateMode?: \"Automatic\" | \"Manual\";\n FeePercent?: string; // decimal string\n Targets?: ConversionTarget[]; // whitelist of valid conversion targets\n}\n\ninterface ConversionTarget {\n TargetCurrencyType?: \"Virtual\" | \"Crypto\";\n TargetCurrencyID: string;\n Rate?: string; // decimal string; used when RateMode is \"Manual\"\n MinAmount?: number;\n MaxAmount?: number;\n DailyLimit?: number;\n}\n```\n\nA conversion is only accepted if the source currency's `Conversion.Enabled`\nis true and the target appears in `Targets` (by type + id). `\"Automatic\"`\nrate mode means the backend derives the rate from each side's `ValueInUSD`;\n`\"Manual\"` uses the `Rate` pinned on the `ConversionTarget`. Either way, treat\n`RateApplied` on the response as the source of truth — don't recompute it.\n\n---\n\n## Backend conversion formulas\n\nTranscribed from `ConversionService.ConvertAsync` /\n`ConversionService.ConvertCryptoAsync` in the backend\n(`IDosGamesSDK/API/Client/v2/Currency/Services/ConversionService.cs`). These\nare enforced server-side; the SDK never recomputes them — use this section\nonly for building accurate cost/reward **previews**, not for validating a\nconversion before sending it.\n\n**Order of checks** (any failure short-circuits, no partial debit):\n\n1. Basic shape: non-empty `SourceID`/`TargetID`, positive amount, source ≠\n target.\n2. `Convert` (VC↔VC) rejects a `Crypto` source outright (\"Convert supports\n VC↔VC only\") and a `Crypto` target outright (\"Virtual→Crypto conversion is\n not supported\"). `CryptoConvert` rejects a non-`Crypto` source outright\n (\"CryptoConvert requires source to be Crypto\").\n3. **Status**: source or target `Maintenance` → rejected on that side (\"is\n under maintenance\"). Target (only) `Deprecated` → rejected (\"is deprecated\n and cannot receive new credits\"). A `Deprecated` **source** is allowed —\n deprecating a currency only stops new inflow, it doesn't trap the player's\n remaining balance.\n4. Source's `Conversion` must be non-null and `Enabled`, and must have a\n `Targets` entry matching `(TargetCurrencyType, TargetCurrencyID)` exactly —\n otherwise \"Conversion from 'X' to 'Y' is not allowed.\"\n5. Crypto-source → Virtual-target additionally requires\n `CryptoCurrencyPermissions.ConvertibleToVirtual` — false rejects even a\n listed target.\n6. Per-pair `MinAmount`/`MaxAmount` on the matched `ConversionTarget`, checked\n against the raw source amount before fee.\n7. **Rate resolution**:\n - `RateMode = Manual` → `rate = ConversionTarget.Rate`; a pair configured\n Manual with no `Rate` set is rejected (\"Manual conversion rate is not set\n for this pair\"), not treated as 0 or 1.\n - `RateMode = Automatic` → `rate = source.ValueInUSD / target.ValueInUSD`.\n Either side missing/zero `ValueInUSD` rejects the conversion (\"Automatic\n rate cannot be computed\").\n8. **Fee**: `FeePercent` is clamped to `[0, 100]` defensively, then\n `feeAmount = sourceAmount * FeePercent / 100`; `netSource = sourceAmount -\nfeeAmount`. `netSource <= 0` is rejected.\n9. **Output**: `output = netSource * rate`.\n - VC↔VC (`ConvertAsync`): `output` is cast straight to `long`, i.e.\n **truncated toward zero**. `output <= 0` after truncation is rejected\n (\"Resulting target amount is zero\").\n - Crypto-source (`ConvertCryptoAsync`): `output` stays a full-precision\n `decimal` if the target is `Crypto`. If the target is `Virtual`, it is\n **floored** (`Math.Floor`) to a `long` before the zero-check.\n10. Per-pair `DailyLimit` on the matched `ConversionTarget`: today's\n already-converted amount for this exact `(SourceType:SourceID ->\nTargetType:TargetID)` pair (tracked server-side per UTC day; not exposed\n to the client) plus this operation's raw source amount must not exceed\n it.\n11. The debit/credit itself runs through `ResourceService\n.ApplyResourceOperationAtomicAsync`, which additionally enforces the\n source currency's own `Economy.MinBalance`/`MaxBalance` (for VC) and\n `Economy.DailyEarnLimit`/`DailySpendLimit` — a **second, independent**\n limit layer scoped to the whole currency rather than this one pair. A\n crypto source's live balance is checked directly against\n `InventoryV2.CryptoCurrencies[id].Amount` before the debit.\n\n**Practical takeaway**: two conversions that look identical (same pair, same\namount) can differ in outcome depending on how much of the _daily_ pair\nallowance or the _daily_ currency-wide allowance is already used — always\nrender the server's `error` rather than trying to precompute eligibility.\n\n---\n\n## The shared resource primitives\n\nThis is the **canonical documentation** for these types — every other module\nskill (Store, Character, Craft, Lootbox, Blockchain, …) links here instead of\nredefining them. They describe \"spend this, receive that\" in one uniform\nshape used for offer costs, upgrade costs, craft inputs/outputs, lootbox\nprices/rewards, and blockchain deposit/withdrawal resource deltas.\n\n### ResourceEntry\n\nThe atomic unit: one currency or item quantity.\n\n```ts\ninterface ResourceEntry {\n Type?: \"Item\" | \"VirtualCurrency\" | \"CryptoCurrency\" | \"UsdCent\"; // ResourceEntryType — exactly these 4\n CurrencyID?: string; // set when Type is a currency kind\n Amount?: number; // integer amount; C# `long` on the wire, parsed via zVcAmount (exact up to 2^53-1)\n CatalogID?: string; // set when Type is \"Item\": which catalog\n ItemID?: string; // set when Type is \"Item\": which item definition\n}\n```\n\nOnly the fields relevant to `Type` are populated — e.g. a `VirtualCurrency`\nentry sets `CurrencyID` + `Amount` and leaves `CatalogID`/`ItemID` unset; an\n`Item` entry sets `CatalogID`/`ItemID` (+ `Amount` for stackable quantity) and\nleaves `CurrencyID` unset.\n\n### ResourceBundle\n\nA flat list of entries, plus optional event-token deltas:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[] | null;\n EventTokens?: EventTokenOperation[] | null;\n}\n```\n\n### PremiumTierBundle\n\nAn alternate bundle that only applies if the player holds a qualifying\npremium tier — used inside `ResourceGrant`/`ResourceConsume` to express\n\"VIPs get a better grant / a cheaper cost.\"\n\n```ts\ninterface PremiumTierBundle {\n MinPremiumTier?: number;\n RequiredPremiumID?: string;\n Resources?: ResourceBundle | null;\n}\n```\n\n### ResourceGrant\n\nWhat a player receives.\n\n```ts\ninterface ResourceGrant {\n Standard?: ResourceBundle | null; // baseline grant, always applies\n PremiumBonuses?: unknown[] | null; // reserved/opaque bonus list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated additional/alternate grants\n}\n```\n\n### ResourceConsume\n\nWhat a player is charged. Mirror-shaped to `ResourceGrant`, but the\ntier-based array is a **discount** mechanism rather than a bonus one — see\nGotchas below.\n\n```ts\ninterface ResourceConsume {\n Standard?: ResourceBundle | null; // baseline cost\n PremiumDiscounts?: unknown[] | null; // reserved/opaque discount list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated reduced/alternate cost\n}\n```\n\n### ResourceOperation\n\nThe full manifest for one action: what's granted and what's consumed. This is\nthe shape every \"did an action succeed\" response embeds under a `Resources`\nfield (e.g. `DepositNFTResponse.Resources`, `NFTWithdrawalResponse.Resources`\nin Blockchain).\n\n```ts\ninterface ResourceOperation {\n Grant?: ResourceGrant | null;\n Consume?: ResourceConsume | null;\n}\n```\n\nEither side can be `null`/absent — a pure grant (no cost) sets only `Grant`;\na pure charge (no payout) sets only `Consume`.\n\n### EventTokenOperation / EventTokenAddress\n\nEvent tokens are a lighter-weight counter mechanic (e.g. season/event\ncurrency) addressed by an entity rather than a flat `CurrencyID`:\n\n```ts\ninterface EventTokenAddress {\n Type?: string; // EventTokenType — which kind of entity owns the token bucket\n EntityID: string;\n}\n\ninterface EventTokenOperation {\n Address?: EventTokenAddress | null;\n Amount?: number;\n Source?: string; // free-form provenance tag\n}\n```\n\n### ResourceDualPartyResult / ResourceTransferResult\n\nUsed by PvP/transfer-style features where two accounts are affected by one\naction:\n\n```ts\n// Each side gets its own independent grant/consume manifest.\ninterface ResourceDualPartyResult {\n FromUserID?: string;\n ToUserID?: string;\n FromResult?: ResourceOperation | null;\n ToResult?: ResourceOperation | null;\n}\n\n// A straight transfer: one bundle moves from one account to another.\ninterface ResourceTransferResult {\n FromUserID?: string;\n ToUserID?: string;\n Transferred?: ResourceBundle | null;\n}\n```\n\n---\n\n## How the SDK applies a ResourceOperation\n\nEvery module that returns a `ResourceOperation` (directly, or via a\n`Resources` field) has already had it **applied server-side**; the SDK's job\nis only to mirror it into the local cache so balances/inventory read\ncorrectly without a re-fetch. Internally this goes through\n`UserData.applyResourceOperation(op, itemDefs)`, which:\n\n- walks `Consume.Standard.Entries` and `Grant.Standard.Entries` (the\n `PremiumDiscounts`/`PremiumTiers`/`PremiumBonuses` arrays describe _why_ the\n standard amount is what it is — the server has already resolved them into\n `Standard` before sending the response; the client does not re-apply tiers),\n- for `VirtualCurrency` entries, adjusts the integer balance and emits\n `user:virtualCurrencyUpdated`,\n- for `Item` entries, adjusts stackable counts / creates unstackable instances\n and emits `user:inventoryUpdated`,\n- for `EventTokens`, adjusts the addressed token bucket and emits\n `user:eventTokenUpdated`,\n- always emits the umbrella `user:anyUpdated` when anything changed.\n\n`CryptoCurrency` amounts do **not** flow through this integer pipeline —\nthey're decimal and go through a separate patch\n(`UserData.patchCryptoCurrencyDelta(currencyID, delta, serverTimeUtc)`), which\nis what `CurrencyService.cryptoConvert` and the Blockchain deposit/withdrawal\nmethods use directly instead of embedding crypto deltas in a\n`ResourceOperation`.\n\n**Practical takeaway when building UI in any module:** don't hand-roll cost\npreviews from `PremiumDiscounts`/`PremiumTiers` internals unless you're\nexplicitly building a \"your VIP tier saves you N%\" comparison — for \"what will\nthis cost me right now,\" prefer the value the server already resolved\n(`Standard`, or the flat response fields like `ConvertResponse.SourceSpent`).\nTreat `Amount` on VC entries as a signed integer conceptually (consume vs.\ngrant is which container it's in, not a negative number) and crypto strings as\nopaque decimal values to hand to `decimal.js`, not to parse with `Number()`\nonce you're near precision limits.\n"
|
|
8
|
+
"content": "# Currency data model — reference\n\nFull shape of the `CurrencyDefinitions` config, and — the canonical\ndocumentation for the whole SDK — the shared `ResourceConsume` /\n`ResourceGrant` / `ResourceOperation` / `ResourceEntry` cost-and-reward\nprimitives. All types are **strictly typed** and exported from\n`@idosgames/core`; every object schema keeps `.passthrough()`, so a field the\nbackend adds later still round-trips instead of being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CurrencyDefinitions](#config-currencydefinitions)\n- [VirtualCurrencyDefinition](#virtualcurrencydefinition)\n- [CryptoCurrencyDefinition](#cryptocurrencydefinition)\n- [Shared conversion config](#shared-conversion-config)\n- [Backend conversion formulas](#backend-conversion-formulas) — transcribed from `ConversionService.cs`\n- [The shared resource primitives](#the-shared-resource-primitives) — canonical home\n - [ResourceEntry](#resourceentry)\n - [ResourceBundle](#resourcebundle)\n - [PremiumTierBundle](#premiumtierbundle)\n - [ResourceGrant](#resourcegrant)\n - [ResourceConsume](#resourceconsume)\n - [ResourceOperation](#resourceoperation)\n - [EventTokenOperation / EventTokenAddress](#eventtokenoperation--eventtokenaddress)\n - [ResourceDualPartyResult / ResourceTransferResult](#resourcedualpartyresult--resourcetransferresult)\n- [How the SDK applies a ResourceOperation](#how-the-sdk-applies-a-resourceoperation)\n\n---\n\n## Config: CurrencyDefinitions\n\n```ts\ninterface CurrencyDefinitions {\n VirtualCurrencies?: Record<string, VirtualCurrencyDefinition> | null;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition> | null;\n}\n```\n\nKey in both maps is the `CurrencyID`. A currency is \"known\" iff it has an\nentry in one of these maps under its `CurrencyType` (`Virtual` or `Crypto`).\n\n---\n\n## VirtualCurrencyDefinition\n\n```ts\ninterface VirtualCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>; // \"icon\", ...\n Economy?: {\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string; // ISO timestamp\n InitialDeposit?: number; // starting balance for new players\n MinBalance?: number;\n MaxBalance?: number;\n DailyEarnLimit?: number;\n DailySpendLimit?: number;\n };\n Recharge?: {\n // energy-style auto-regen\n Rate?: number;\n Max?: number;\n Period?: number;\n };\n Conversion?: CurrencyConversion; // see below\n Permissions?: {\n IsTradable?: boolean;\n IsPurchasable?: boolean;\n IsRefundable?: boolean;\n };\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n```\n\n`Status` governs whether the currency is usable/visible; `\"Maintenance\"` /\n`\"Deprecated\"` currencies typically reject conversions server-side even if\n`Conversion.Enabled` is true.\n\n---\n\n## CryptoCurrencyDefinition\n\n```ts\ninterface CryptoCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n DisplayDecimals?: number; // UI rounding, not wire precision\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string;\n DeveloperDepositSharePercent?: string; // decimal string; see blockchain-system\n Networks?: CryptoNetworkBinding[]; // per-chain bindings\n Limits?: {\n DailyWithdrawUsd?: string;\n MonthlyWithdrawUsd?: string;\n KycRequiredAboveUsd?: string;\n };\n Permissions?: {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n SpendableInGame?: boolean;\n ConvertibleToVirtual?: boolean; // gates cryptoConvert eligibility\n };\n Conversion?: CurrencyConversion;\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n\ninterface CryptoNetworkBinding {\n NetworkID: string;\n ContractAddress?: string;\n Decimals?: number; // on-chain token decimals\n MinDeposit?: string;\n MinWithdraw?: string;\n WithdrawFee?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nDeposit/withdrawal flows (and `Networks`/`Limits` enforcement for those flows)\nbelong to the Blockchain module — see the blockchain-system skill.\n`Permissions.ConvertibleToVirtual` is the flag most relevant here: it's what\nlets a crypto balance participate in `cryptoConvert`.\n\n---\n\n## Shared conversion config\n\nBoth currency kinds reuse the same `CurrencyConversion` shape for their\n`Conversion` field:\n\n```ts\ninterface CurrencyConversion {\n Enabled?: boolean;\n RateMode?: \"Automatic\" | \"Manual\";\n FeePercent?: string; // decimal string\n Targets?: ConversionTarget[]; // whitelist of valid conversion targets\n}\n\ninterface ConversionTarget {\n TargetCurrencyType?: \"Virtual\" | \"Crypto\";\n TargetCurrencyID: string;\n Rate?: string; // decimal string; used when RateMode is \"Manual\"\n MinAmount?: number;\n MaxAmount?: number;\n DailyLimit?: number;\n}\n```\n\nA conversion is only accepted if the source currency's `Conversion.Enabled`\nis true and the target appears in `Targets` (by type + id). `\"Automatic\"`\nrate mode means the backend derives the rate from each side's `ValueInUSD`;\n`\"Manual\"` uses the `Rate` pinned on the `ConversionTarget`. Either way, treat\n`RateApplied` on the response as the source of truth — don't recompute it.\n\n---\n\n## Backend conversion formulas\n\nTranscribed from `ConversionService.ConvertAsync` /\n`ConversionService.ConvertCryptoAsync` in the backend\n(`IDosGamesSDK/API/Client/v2/Currency/Services/ConversionService.cs`). These\nare enforced server-side; the SDK never recomputes them — use this section\nonly for building accurate cost/reward **previews**, not for validating a\nconversion before sending it.\n\n**Order of checks** (any failure short-circuits, no partial debit):\n\n1. Basic shape: non-empty `SourceID`/`TargetID`, positive amount, source ≠\n target.\n2. `Convert` (VC↔VC) rejects a `Crypto` source outright (\"Convert supports\n VC↔VC only\") and a `Crypto` target outright (\"Virtual→Crypto conversion is\n not supported\"). `CryptoConvert` rejects a non-`Crypto` source outright\n (\"CryptoConvert requires source to be Crypto\").\n3. **Status**: source or target `Maintenance` → rejected on that side (\"is\n under maintenance\"). Target (only) `Deprecated` → rejected (\"is deprecated\n and cannot receive new credits\"). A `Deprecated` **source** is allowed —\n deprecating a currency only stops new inflow, it doesn't trap the player's\n remaining balance.\n4. Source's `Conversion` must be non-null and `Enabled`, and must have a\n `Targets` entry matching `(TargetCurrencyType, TargetCurrencyID)` exactly —\n otherwise \"Conversion from 'X' to 'Y' is not allowed.\"\n5. Crypto-source → Virtual-target additionally requires\n `CryptoCurrencyPermissions.ConvertibleToVirtual` — false rejects even a\n listed target.\n6. Per-pair `MinAmount`/`MaxAmount` on the matched `ConversionTarget`, checked\n against the raw source amount before fee.\n7. **Rate resolution**:\n - `RateMode = Manual` → `rate = ConversionTarget.Rate`; a pair configured\n Manual with no `Rate` set is rejected (\"Manual conversion rate is not set\n for this pair\"), not treated as 0 or 1.\n - `RateMode = Automatic` → `rate = source.ValueInUSD / target.ValueInUSD`.\n Either side missing/zero `ValueInUSD` rejects the conversion (\"Automatic\n rate cannot be computed\").\n8. **Fee**: `FeePercent` is clamped to `[0, 100]` defensively, then\n `feeAmount = sourceAmount * FeePercent / 100`; `netSource = sourceAmount -\nfeeAmount`. `netSource <= 0` is rejected.\n9. **Output**: `output = netSource * rate`.\n - VC↔VC (`ConvertAsync`): `output` is cast straight to `long`, i.e.\n **truncated toward zero**. `output <= 0` after truncation is rejected\n (\"Resulting target amount is zero\").\n - Crypto-source (`ConvertCryptoAsync`): `output` stays a full-precision\n `decimal` if the target is `Crypto`. If the target is `Virtual`, it is\n **floored** (`Math.Floor`) to a `long` before the zero-check.\n10. Per-pair `DailyLimit` on the matched `ConversionTarget`: today's\n already-converted amount for this exact `(SourceType:SourceID ->\nTargetType:TargetID)` pair (tracked server-side per UTC day; not exposed\n to the client) plus this operation's raw source amount must not exceed\n it.\n11. The debit/credit itself runs through `ResourceService\n.ApplyResourceOperationAtomicAsync`, which additionally enforces the\n source currency's own `Economy.MinBalance`/`MaxBalance` (for VC) and\n `Economy.DailyEarnLimit`/`DailySpendLimit` — a **second, independent**\n limit layer scoped to the whole currency rather than this one pair. A\n crypto source's live balance is checked directly against\n `InventoryV2.CryptoCurrencies[id].Amount` before the debit.\n\n**Practical takeaway**: two conversions that look identical (same pair, same\namount) can differ in outcome depending on how much of the _daily_ pair\nallowance or the _daily_ currency-wide allowance is already used — always\nrender the server's `error` rather than trying to precompute eligibility.\n\n---\n\n## The shared resource primitives\n\nThis is the **canonical documentation** for these types — every other module\nskill (Store, Character, Craft, Lootbox, Blockchain, …) links here instead of\nredefining them. They describe \"spend this, receive that\" in one uniform\nshape used for offer costs, upgrade costs, craft inputs/outputs, lootbox\nprices/rewards, and blockchain deposit/withdrawal resource deltas.\n\n### ResourceEntry\n\nThe atomic unit: one currency or item quantity.\n\n```ts\ninterface ResourceEntry {\n Type?:\n | \"Item\"\n | \"VirtualCurrency\"\n | \"CryptoCurrency\"\n | \"Purchase\"\n | \"RewardedVideoCredit\"; // ResourceEntryType\n CurrencyID?: string; // set when Type is a currency kind\n Amount?: number; // integer amount; C# `long` on the wire, parsed via zVcAmount (exact up to 2^53-1)\n CatalogID?: string; // set when Type is \"Item\": which catalog\n ItemID?: string; // set when Type is \"Item\": which item definition\n ProductID?: string; // set when Type is \"Purchase\": the IAP product that pays for this entry\n}\n```\n\nOnly the fields relevant to `Type` are populated — e.g. a `VirtualCurrency`\nentry sets `CurrencyID` + `Amount` and leaves `CatalogID`/`ItemID` unset; an\n`Item` entry sets `CatalogID`/`ItemID` (+ `Amount` for stackable quantity) and\nleaves `CurrencyID` unset.\n\n### ResourceBundle\n\nA flat list of entries, plus optional event-token deltas:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[] | null;\n EventTokens?: EventTokenOperation[] | null;\n}\n```\n\n### PremiumTierBundle\n\nAn alternate bundle that only applies if the player holds a qualifying\npremium tier — used inside `ResourceGrant`/`ResourceConsume` to express\n\"VIPs get a better grant / a cheaper cost.\"\n\n```ts\ninterface PremiumTierBundle {\n MinPremiumTier?: number;\n RequiredPremiumID?: string;\n Resources?: ResourceBundle | null;\n}\n```\n\n### ResourceGrant\n\nWhat a player receives.\n\n```ts\ninterface ResourceGrant {\n Standard?: ResourceBundle | null; // baseline grant, always applies\n PremiumBonuses?: unknown[] | null; // reserved/opaque bonus list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated additional/alternate grants\n}\n```\n\n### ResourceConsume\n\nWhat a player is charged. Mirror-shaped to `ResourceGrant`, but the\ntier-based array is a **discount** mechanism rather than a bonus one — see\nGotchas below.\n\n```ts\ninterface ResourceConsume {\n Standard?: ResourceBundle | null; // baseline cost\n PremiumDiscounts?: unknown[] | null; // reserved/opaque discount list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated reduced/alternate cost\n}\n```\n\n### ResourceOperation\n\nThe full manifest for one action: what's granted and what's consumed. This is\nthe shape every \"did an action succeed\" response embeds under a `Resources`\nfield (e.g. `DepositNFTResponse.Resources`, `NFTWithdrawalResponse.Resources`\nin Blockchain).\n\n```ts\ninterface ResourceOperation {\n Grant?: ResourceGrant | null;\n Consume?: ResourceConsume | null;\n}\n```\n\nEither side can be `null`/absent — a pure grant (no cost) sets only `Grant`;\na pure charge (no payout) sets only `Consume`.\n\n### EventTokenOperation / EventTokenAddress\n\nEvent tokens are a lighter-weight counter mechanic (e.g. season/event\ncurrency) addressed by an entity rather than a flat `CurrencyID`:\n\n```ts\ninterface EventTokenAddress {\n Type?: string; // EventTokenType — which kind of entity owns the token bucket\n EntityID: string;\n}\n\ninterface EventTokenOperation {\n Address?: EventTokenAddress | null;\n Amount?: number;\n Source?: string; // free-form provenance tag\n}\n```\n\n### ResourceDualPartyResult / ResourceTransferResult\n\nUsed by PvP/transfer-style features where two accounts are affected by one\naction:\n\n```ts\n// Each side gets its own independent grant/consume manifest.\ninterface ResourceDualPartyResult {\n FromUserID?: string;\n ToUserID?: string;\n FromResult?: ResourceOperation | null;\n ToResult?: ResourceOperation | null;\n}\n\n// A straight transfer: one bundle moves from one account to another.\ninterface ResourceTransferResult {\n FromUserID?: string;\n ToUserID?: string;\n Transferred?: ResourceBundle | null;\n}\n```\n\n---\n\n## How the SDK applies a ResourceOperation\n\nEvery module that returns a `ResourceOperation` (directly, or via a\n`Resources` field) has already had it **applied server-side**; the SDK's job\nis only to mirror it into the local cache so balances/inventory read\ncorrectly without a re-fetch. Internally this goes through\n`UserData.applyResourceOperation(op, itemDefs)`, which:\n\n- walks `Consume.Standard.Entries` and `Grant.Standard.Entries` (the\n `PremiumDiscounts`/`PremiumTiers`/`PremiumBonuses` arrays describe _why_ the\n standard amount is what it is — the server has already resolved them into\n `Standard` before sending the response; the client does not re-apply tiers),\n- for `VirtualCurrency` entries, adjusts the integer balance and emits\n `user:virtualCurrencyUpdated`,\n- for `Item` entries, adjusts stackable counts / creates unstackable instances\n and emits `user:inventoryUpdated`,\n- for `EventTokens`, adjusts the addressed token bucket and emits\n `user:eventTokenUpdated`,\n- always emits the umbrella `user:anyUpdated` when anything changed.\n\n`CryptoCurrency` amounts do **not** flow through this integer pipeline —\nthey're decimal and go through a separate patch\n(`UserData.patchCryptoCurrencyDelta(currencyID, delta, serverTimeUtc)`), which\nis what `CurrencyService.cryptoConvert` and the Blockchain deposit/withdrawal\nmethods use directly instead of embedding crypto deltas in a\n`ResourceOperation`.\n\n**Practical takeaway when building UI in any module:** don't hand-roll cost\npreviews from `PremiumDiscounts`/`PremiumTiers` internals unless you're\nexplicitly building a \"your VIP tier saves you N%\" comparison — for \"what will\nthis cost me right now,\" prefer the value the server already resolved\n(`Standard`, or the flat response fields like `ConvertResponse.SourceSpent`).\nTreat `Amount` on VC entries as a signed integer conceptually (consume vs.\ngrant is which container it's in, not a negative number) and crypto strings as\nopaque decimal values to hand to `decimal.js`, not to parse with `Number()`\nonce you're near precision limits.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|