@idosgames/mcp 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +19 -3
- package/registry/index.json +119 -23
- package/registry/modules/board-game.json +31 -10
- package/registry/modules/game-hud.json +99 -0
- package/registry/modules/idle-rpg.json +35 -10
- package/registry/modules/voxelcraft.json +7 -2
- package/registry/skills/acquisition-attribution.json +1 -1
- package/registry/skills/blockchain-system.json +2 -2
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/chat-system.json +6 -0
- package/registry/skills/community-marketing-system.json +6 -0
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/currency-system.json +2 -2
- package/registry/skills/deal-offer-system.json +2 -2
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +6 -0
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/push-notifications.json +6 -0
- package/registry/skills/store-system.json +3 -3
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "item-system",
|
|
3
3
|
"description": "Work with items on the iDosGames TypeScript SDK (@idosgames/core) via client.item (ItemService) and the shared Item data model: upgrade an item instance's level (single or batch, optionally consuming fodder instances), and understand ItemDefinition / item catalogs / stackable vs unstackable item instances / equipment rules — the vocabulary Character (equipment), Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store (purchase grants) all build on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item upgrade/leveling UIs, inventory screens, item definitions/catalogs, stackable/unstackable item instances, item rarity/tags, NFT-bound items, or otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog, UnstackableItemInstanceState, or InventoryV2 — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: item-system\ndescription: >-\n Work with items on the iDosGames TypeScript SDK (@idosgames/core) via\n client.item (ItemService) and the shared Item data model: upgrade an item\n instance's level (single or batch, optionally consuming fodder instances),\n and understand ItemDefinition / item catalogs / stackable vs unstackable\n item instances / equipment rules — the vocabulary Character (equipment),\n Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store\n (purchase grants) all build on. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants\n item upgrade/leveling UIs, inventory screens, item definitions/catalogs,\n stackable/unstackable item instances, item rarity/tags, NFT-bound items, or\n otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog,\n UnstackableItemInstanceState, or InventoryV2 — even if they don't name the\n module explicitly.\n---\n\n# Item system (iDosGames TS SDK)\n\nThe Item module has two very different halves. `ItemService` itself is small\n— it only upgrades an item instance's level (single or batch, optionally\nburning fodder instances). But `ItemDefinition` — the config shape for what an\nitem _is_ — is the shared vocabulary every other module builds on: Character\nequips item instances into slots, Marketplace lists/auctions/trades them,\nCraft burns them as recipe inputs and mints them as outputs, Lootbox grants\nthem as rewards, and Store sells them in offer bundles. This skill covers\nboth: the upgrade-level methods you call directly, and the item data model\nyou'll read constantly from every other module's config and responses.\n\nEverything is **server-authoritative**: the client asks the backend to\nupgrade, the backend validates cost/cap/fodder and applies the change, and the\nSDK mirrors the confirmed result into a local cache your UI reads. This skill\nis for **using** the production `ItemService` and reading the item model, not\nfor porting or extending it. If a call is rejected, that's the backend\nenforcing a rule (cost, level cap, fodder mismatch, stale catalog) — surface\nthe error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n item templates: `ItemDefinition` keyed by `ItemID`, grouped into\n `ItemCatalog`s keyed by `CatalogID`, all under the root `ItemDefinitions`.\n Fetched at the title level (see the title-system skill for\n `getItemDefinitions()`), not through `client.item`.\n2. **Item instances** (state, per player) — what a player actually owns, held\n in `client.data.user.state?.InventoryV2`. Two different shapes depending\n on whether the item stacks — see below. `client.item` reads and writes\n only unstackable instances (the ones with a per-instance `Level` to\n upgrade).\n\nA definition's full address is the pair `(CatalogID, ItemID)` — `ItemID` is\nonly unique **within** a catalog, so the same `ItemID` can appear in more than\none catalog. Whether an instance is stackable or not is fixed by\n`ItemDefinition.IsStackable`. See\n[references/data-model.md](references/data-model.md) for the full field\nreference, the catalog-resolution rule (strict-then-fallback with self-heal),\nand the exact upgrade-cost/fodder formulas.\n\n## Stackable vs unstackable item instances\n\n`InventoryV2` (the `UserInventoryState` cache, at\n`client.data.user.state?.InventoryV2`) carries both kinds side by side:\n\n```ts\ninterface UserInventoryState {\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n```\n\n- **Stackable items** (`ItemDefinition.IsStackable === true`, e.g. crafting\n materials, consumables) have no individual identity — the player just has a\n quantity. They live in `Items[itemID]` as a plain count (`ItemTotals`); there\n is no instance to level up, equip, or track expiry on.\n- **Unstackable items** (`IsStackable` false/absent, e.g. weapons, armor,\n collectibles) are each a distinct instance with its own id, level, and\n lifecycle. They live in `UnstackableItems[itemInstanceID]`:\n\n ```ts\n interface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size — see references/data-model.md\n RemainingUses?: number;\n Level?: number; // what upgradeLevel() raises; default 1\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for \"is this equipped\"\n CustomData?: string | null;\n }\n ```\n\n`EquippedSlot` is the **authoritative** record of whether/where an instance is\nequipped — the Character module's per-character `Equipment` map is just a\ncache view of the same fact. See the character-system skill (equip/unequip\nmethods, slot rules) for how items get placed into `EquippedSlot`; this skill\nowns the instance side (`Level`, `ExpiresAt`, `Quantity`).\n\n`ItemService.upgradeLevel` operates on `UnstackableItems` entries only — you\npass an `ItemInstanceID`, and its `Level` is what changes. A `Quantity > 1`\ninstance is a merged pack of identical untouched copies; upgrading one copy\nout of a pack causes the server to split off a fresh instance id for it — see\nreferences/data-model.md for exactly when that happens and why the response's\n`ItemInstanceID` can differ from what you called with.\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 items = client.item; // the ItemService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nBoth 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. empty `ItemInstanceID`), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window,\ndefault 600ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Already at maximum level\n(3/3).\"`, `\"Item instance 'inst-1' not found.\"`, `\"Item instance 'inst-1' has\nexpired.\"`, `\"Item 'sword' is not upgradable.\"`, or a fodder-coverage\nshortfall).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `upgradeLevel(itemInstanceID, fodderInstanceIDs?)` | Raise one item instance's level by one, optionally burning fodder instances. | `UpgradeItemLevelResponse` (`Level`, `FodderConsumed`) |\n| `upgradeLevelsBatch(upgrades: ItemUpgradeRef[])` | Upgrade several item instances in one atomic call (each with its own multi-level/fodder options). | `UpgradeLevelsBatchResponse` = `BatchItemResult<UpgradeItemLevelResponse>[]` |\n\n`ItemUpgradeRef` (used only inside `upgradeLevelsBatch`):\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to the cap\n FodderInstanceIDs?: string[]; // instances burned to pay for this instance's upgrade\n}\n```\n\nOn success, both methods **re-fetch the server-authoritative inventory**\n(`client.userService.getUserInventory()` internally) and mirror it wholesale\ninto `client.data.user.state?.InventoryV2` — they don't hand-patch the one\ninstance you upgraded. Read the new `Level` off the refreshed cache, or off\n`result.data.Level` directly. Both also emit an event; the coarse\n`user:inventoryUpdated` (+ `user:anyUpdated`) fires as part of that inventory\nrefresh too.\n\n## Reading state and reacting to changes\n\n```ts\n// Current unstackable instances (only present after login/getUserInventory/upgradeLevel):\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\ninst?.Level; // current level\ninst?.EquippedSlot; // where it's equipped, if anywhere\n\n// Stackable item counts:\nconst totals = client.data.user.state?.InventoryV2?.Items?.[\"potion\"];\ntotals?.TotalAmount;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `item:levelUpgraded` → `UpgradeItemLevelResponse`\n- `item:levelsUpgradedBatch` → `UpgradeLevelsBatchResponse`\n\nThe coarse `user:inventoryUpdated` (and `user:anyUpdated`) also fire on the\ninventory refresh that follows every successful upgrade — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"item:levelUpgraded\", (r) => {\n console.log(`${r.ItemInstanceID} is now level ${r.Level}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Upgrade one item instance\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\");\nif (!res.ok) return showError(res.error); // e.g. \"Already at maximum level (3/3).\"\nres.data.Level; // new level\nres.data.ItemInstanceID; // may differ from \"inst-123\" if it split off a pristine pack — see below\nres.data.FodderConsumed; // [] unless a weighted (Merge/InvestmentRefund) fodder mode applied\n// client.data.user.state?.InventoryV2 has already been re-fetched.\n```\n\n### Upgrade with fodder (burn other instances to pay the cost)\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\", [\n \"inst-456\",\n \"inst-789\",\n]);\nif (!res.ok) return showError(res.error);\nfor (const f of res.data.FodderConsumed ?? []) {\n console.log(\n `burned ${f.ItemInstanceID} (was level ${f.Level}, ${f.Units} units)`,\n );\n}\n```\n\n`fodderInstanceIDs` is only meaningful when the item's config defines a fodder\nvaluation mode (`ItemDefinition.Upgrade.Fodder`) that requires client\nselection (`Selection: \"ClientSelected\"`) — otherwise the server auto-picks\nfodder itself (`ProtectLeveled`/`CheapestFirst`), or there's no self-item cost\nat all and any fodder you pass is simply rejected as a mismatch. Passing\nfodder that's a different item, already equipped, expired, or already claimed\nelsewhere in the same batch is rejected by instance id. See\nreferences/data-model.md for the exact valuation math (`W(level)` per mode)\nand selection rules.\n\n### Multi-level upgrade to an absolute target\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"inst-123\", TargetLevel: 10 },\n]);\nif (!res.ok) return showError(res.error); // outer call-level failure\nconst [item] = res.data;\nif (!item.Success) return showItemError(item.Id, item.Error);\n```\n\n`upgradeLevelsBatch` is the only way to pass `Levels`/`TargetLevel` from the\nSDK — the single `upgradeLevel` call only ever raises by one level per call\n(even though the underlying backend request shape supports a multi-level jump\non the single action too, the SDK doesn't expose it that way). To jump several\nlevels on a single instance in one shot, call the batch method with one entry.\nThe charge is the **sum** of each level's cost in the range, not a single\nlump price for the destination level — see references/data-model.md for the\nformula.\n\n### Batch-upgrade several instances at once\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"sword-1\", Levels: 2 },\n { ItemInstanceID: \"shield-1\" }, // Levels defaults to 1\n { ItemInstanceID: \"bow-1\", FodderInstanceIDs: [\"bow-2\", \"bow-3\"] },\n]);\nif (!res.ok) return showError(res.error);\nfor (const entry of res.data) {\n if (entry.Success) applyOk(entry.Id);\n else showItemError(entry.Id, entry.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that instance's\nupgrade applied. The resource charge across the batch is atomic/merged — if\nthe combined cost can't be paid, every included item comes back\n`Success: false`. Refs are deduped by `ItemInstanceID` server-side, and the\nserver processes at most **50 entries per call** — entries past 50 are\nsilently dropped and don't appear in the results at all, so chunk larger sets\ninto multiple calls yourself. An `ItemInstanceID` containing `.` or `$` is\nrejected per-entry rather than failing the whole batch.\n\n### Read the catalog for display (rarity, tags, upgrade cap)\n\n```ts\nimport type { ItemDefinitions } from \"@idosgames/core\";\n\nfunction findItemDef(defs: ItemDefinitions | undefined, itemID: string) {\n for (const catalog of Object.values(defs?.Catalogs ?? {})) {\n const def = catalog.Items?.[itemID];\n if (def) return def; // first match; see references/data-model.md if itemID isn't unique title-wide\n }\n return undefined;\n}\n\nconst defs = client.data.config.itemDefinitions; // ItemDefinitions | undefined\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\nconst def = inst && findItemDef(defs, inst.ItemID);\ndef?.Metadata?.RarityID; // \"Epic\", etc — drives UI framing\ndef?.Upgrade?.MaxLevel; // upgrade cap for the progress bar\n```\n\n`client.data.config.itemDefinitions` is a dedicated cache getter (not the\ngeneric `getSection` map other modules use) — it returns whichever came in\nlast: a standalone `client.title.getItemDefinitions()` call, or the `Item`\nblock embedded in the full title config from `getTitlePublicConfiguration()`\n(see the title-system skill for both). When you already know an instance's\n`CatalogID`, look it up directly (`defs.Catalogs?.[catalogID]?.Items?.[itemID]`)\ninstead of scanning — it's the same strict-first rule the backend applies, and\navoids the rare same-`ItemID`-in-two-catalogs ambiguity described in\nreferences/data-model.md.\n\n## Gotchas\n\n- **`upgradeLevel` only ever steps +1.** There's no `levels`/`targetLevel`\n option on the single call — use `upgradeLevelsBatch` with one ref for\n multi-level jumps.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`upgrade_item_{instanceID}_{uuid}`), so two separate calls are two real\n operations — a double-clicked \"Upgrade\" can charge twice. Disable the\n control while a call is in flight. (Firing the same endpoint again within\n the throttle window, default 600ms, is rejected with `reason: \"throttled\"`\n rather than duplicated, but don't rely on that for correctness.)\n- **Render from the refreshed inventory, not a locally patched copy.** The\n service re-fetches `GetUserInventory` on success rather than mutating just\n the one instance — treat `client.data.user.state?.InventoryV2` as the\n source of truth after any upgrade call. In particular, the upgraded\n instance's id in the response can differ from the id you called with (see\n the `Quantity`/pristine-pack split note in references/data-model.md).\n- **Fodder valuation is mode-specific, not \"any spare copy is worth 1.\"**\n `Merge` and `InvestmentRefund` value a fodder copy by its own level\n (`W(level)`, growing super-linearly for `Merge`) — a single high-level\n fodder instance can outweigh several low-level ones, or vice versa, and\n `FodderConsumed` only reports burns for these two weighted modes. Under the\n legacy/`FlatCount` mode, fodder isn't weighted at all: the self-item portion\n of the cost is paid through the ordinary resource-consume pipeline (one\n unit of it is implicitly the instance being upgraded), and `FodderConsumed`\n stays empty even if you pass `fodderInstanceIDs`. Don't build a \"fodder\n value\" UI assuming a flat count applies universally — read\n `ItemDefinition.Upgrade.Fodder.ValuationMode` first.\n- **Catalog IDs can self-heal underneath you.** If an item was moved to a\n different catalog after an instance was granted, the resolver falls back to\n a title-wide scan by `ItemID` and — if unambiguous — silently patches the\n instance's stored `CatalogID` to the resolved one as part of the upgrade.\n Read `CatalogID` off the response/refreshed cache, don't cache it\n separately. If the same `ItemID` now exists in two or more catalogs, the\n fallback refuses to guess and the upgrade fails with a \"not found\" error\n even though the instance still nominally exists — that's a config/data\n issue for the title owner, not something to route around client-side.\n- **Stackable items never appear in `UnstackableItems`.** If\n `ItemDefinition.IsStackable` is true, there is no per-instance `Level` to\n upgrade — `upgradeLevel`/`upgradeLevelsBatch` don't apply to it at all\n (attempting it is rejected as \"stackable; levels are only supported on\n unstackable items\").\n- **Equipment truth lives on the instance, not the character.** `EquippedSlot`\n on `UnstackableItemInstanceState` is authoritative; the Character module's\n per-character `Equipment` map is a synced cache view. Upgrading an equipped\n instance also recomputes and patches its owner's `Power` server-side in the\n same atomic write — see the character-system skill for equip/unequip calls\n and the two-sided character/item slot-rule matrix.\n- **Cost objects are the shared `ResourceConsume`/`ResourceGrant` types.**\n `ItemDefinition.Upgrade.PriceOptions` and the response's `Resources`\n field use the same shared resource model as every other module — see the\n currency-system skill for the full breakdown; briefly, `Resources` on the\n response is a `ResourceOperation` (`{ Grant?, Consume? }`) that's already\n been applied to cached balances by the time you read it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every `ItemDefinition`\nfield (stats, equip rules, upgrade/fodder config, NFT binding, metadata), the\ncatalog/root shape and resolution rule, the upgrade cost formula, and the\nfodder valuation/selection formulas transcribed from the backend. Read it when\nbuilding config-driven UI (upgrade cost previews, fodder pickers, rarity\nbadges) or when an error message points at a config rule you need to\nunderstand.\n",
|
|
4
|
+
"content": "---\nname: item-system\ndescription: >-\n Work with items on the iDosGames TypeScript SDK (@idosgames/core) via\n client.item (ItemService) and the shared Item data model: upgrade an item\n instance's level (single or batch, optionally consuming fodder instances),\n and understand ItemDefinition / item catalogs / stackable vs unstackable\n item instances / equipment rules — the vocabulary Character (equipment),\n Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store\n (purchase grants) all build on. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants\n item upgrade/leveling UIs, inventory screens, item definitions/catalogs,\n stackable/unstackable item instances, item rarity/tags, NFT-bound items, or\n otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog,\n UnstackableItemInstanceState, or InventoryV2 — even if they don't name the\n module explicitly.\n---\n\n# Item system (iDosGames TS SDK)\n\nThe Item module has two very different halves. `ItemService` itself is small\n— it only upgrades an item instance's level (single or batch, optionally\nburning fodder instances). But `ItemDefinition` — the config shape for what an\nitem _is_ — is the shared vocabulary every other module builds on: Character\nequips item instances into slots, Marketplace lists/auctions/trades them,\nCraft burns them as recipe inputs and mints them as outputs, Lootbox grants\nthem as rewards, and Store sells them in offer bundles. This skill covers\nboth: the upgrade-level methods you call directly, and the item data model\nyou'll read constantly from every other module's config and responses.\n\nEverything is **server-authoritative**: the client asks the backend to\nupgrade, the backend validates cost/cap/fodder and applies the change, and the\nSDK mirrors the confirmed result into a local cache your UI reads. This skill\nis for **using** the production `ItemService` and reading the item model, not\nfor porting or extending it. If a call is rejected, that's the backend\nenforcing a rule (cost, level cap, fodder mismatch, stale catalog) — surface\nthe error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n item templates: `ItemDefinition` keyed by `ItemID`, grouped into\n `ItemCatalog`s keyed by `CatalogID`, all under the root `ItemDefinitions`.\n Fetched at the title level (see the title-system skill for\n `getItemDefinitions()`), not through `client.item`.\n2. **Item instances** (state, per player) — what a player actually owns, held\n in `client.data.user.state?.InventoryV2`. Two different shapes depending\n on whether the item stacks — see below. `client.item` reads and writes\n only unstackable instances (the ones with a per-instance `Level` to\n upgrade).\n\nA definition's full address is the pair `(CatalogID, ItemID)` — `ItemID` is\nonly unique **within** a catalog, so the same `ItemID` can appear in more than\none catalog. Whether an instance is stackable or not is fixed by\n`ItemDefinition.IsStackable`. See\n[references/data-model.md](references/data-model.md) for the full field\nreference, the catalog-resolution rule (strict-then-fallback with self-heal),\nand the exact upgrade-cost/fodder formulas.\n\n## Stackable vs unstackable item instances\n\n`InventoryV2` (the `UserInventoryState` cache, at\n`client.data.user.state?.InventoryV2`) carries both kinds side by side:\n\n```ts\ninterface UserInventoryState {\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n```\n\n- **Stackable items** (`ItemDefinition.IsStackable === true`, e.g. crafting\n materials, consumables) have no individual identity — the player just has a\n quantity. They live in `Items[itemID]` as a plain count (`ItemTotals`); there\n is no instance to level up, equip, or track expiry on.\n- **Unstackable items** (`IsStackable` false/absent, e.g. weapons, armor,\n collectibles) are each a distinct instance with its own id, level, and\n lifecycle. They live in `UnstackableItems[itemInstanceID]`:\n\n ```ts\n interface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size — see references/data-model.md\n RemainingUses?: number;\n Level?: number; // what upgradeLevel() raises; default 1\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for \"is this equipped\"\n CustomData?: Record<string, string> | null;\n }\n ```\n\n`EquippedSlot` is the **authoritative** record of whether/where an instance is\nequipped — the Character module's per-character `Equipment` map is just a\ncache view of the same fact. See the character-system skill (equip/unequip\nmethods, slot rules) for how items get placed into `EquippedSlot`; this skill\nowns the instance side (`Level`, `ExpiresAt`, `Quantity`).\n\n`ItemService.upgradeLevel` operates on `UnstackableItems` entries only — you\npass an `ItemInstanceID`, and its `Level` is what changes. A `Quantity > 1`\ninstance is a merged pack of identical untouched copies; upgrading one copy\nout of a pack causes the server to split off a fresh instance id for it — see\nreferences/data-model.md for exactly when that happens and why the response's\n`ItemInstanceID` can differ from what you called with.\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 items = client.item; // the ItemService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nBoth 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. empty `ItemInstanceID`), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window,\ndefault 600ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Already at maximum level\n(3/3).\"`, `\"Item instance 'inst-1' not found.\"`, `\"Item instance 'inst-1' has\nexpired.\"`, `\"Item 'sword' is not upgradable.\"`, or a fodder-coverage\nshortfall).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `upgradeLevel(itemInstanceID, fodderInstanceIDs?)` | Raise one item instance's level by one, optionally burning fodder instances. | `UpgradeItemLevelResponse` (`Level`, `FodderConsumed`) |\n| `upgradeLevelsBatch(upgrades: ItemUpgradeRef[])` | Upgrade several item instances in one atomic call (each with its own multi-level/fodder options). | `UpgradeLevelsBatchResponse` = `BatchItemResult<UpgradeItemLevelResponse>[]` |\n\n`ItemUpgradeRef` (used only inside `upgradeLevelsBatch`):\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to the cap\n FodderInstanceIDs?: string[]; // instances burned to pay for this instance's upgrade\n}\n```\n\nOn success, both methods **re-fetch the server-authoritative inventory**\n(`client.userService.getUserInventory()` internally) and mirror it wholesale\ninto `client.data.user.state?.InventoryV2` — they don't hand-patch the one\ninstance you upgraded. Read the new `Level` off the refreshed cache, or off\n`result.data.Level` directly. Both also emit an event; the coarse\n`user:inventoryUpdated` (+ `user:anyUpdated`) fires as part of that inventory\nrefresh too.\n\n## Reading state and reacting to changes\n\n```ts\n// Current unstackable instances (only present after login/getUserInventory/upgradeLevel):\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\ninst?.Level; // current level\ninst?.EquippedSlot; // where it's equipped, if anywhere\n\n// Stackable item counts:\nconst totals = client.data.user.state?.InventoryV2?.Items?.[\"potion\"];\ntotals?.TotalAmount;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `item:levelUpgraded` → `UpgradeItemLevelResponse`\n- `item:levelsUpgradedBatch` → `UpgradeLevelsBatchResponse`\n\nThe coarse `user:inventoryUpdated` (and `user:anyUpdated`) also fire on the\ninventory refresh that follows every successful upgrade — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"item:levelUpgraded\", (r) => {\n console.log(`${r.ItemInstanceID} is now level ${r.Level}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Upgrade one item instance\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\");\nif (!res.ok) return showError(res.error); // e.g. \"Already at maximum level (3/3).\"\nres.data.Level; // new level\nres.data.ItemInstanceID; // may differ from \"inst-123\" if it split off a pristine pack — see below\nres.data.FodderConsumed; // [] unless a weighted (Merge/InvestmentRefund) fodder mode applied\n// client.data.user.state?.InventoryV2 has already been re-fetched.\n```\n\n### Upgrade with fodder (burn other instances to pay the cost)\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\", [\n \"inst-456\",\n \"inst-789\",\n]);\nif (!res.ok) return showError(res.error);\nfor (const f of res.data.FodderConsumed ?? []) {\n console.log(\n `burned ${f.ItemInstanceID} (was level ${f.Level}, ${f.Units} units)`,\n );\n}\n```\n\n`fodderInstanceIDs` is only meaningful when the item's config defines a fodder\nvaluation mode (`ItemDefinition.Upgrade.Fodder`) that requires client\nselection (`Selection: \"ClientSelected\"`) — otherwise the server auto-picks\nfodder itself (`ProtectLeveled`/`CheapestFirst`), or there's no self-item cost\nat all and any fodder you pass is simply rejected as a mismatch. Passing\nfodder that's a different item, already equipped, expired, or already claimed\nelsewhere in the same batch is rejected by instance id. See\nreferences/data-model.md for the exact valuation math (`W(level)` per mode)\nand selection rules.\n\n### Multi-level upgrade to an absolute target\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"inst-123\", TargetLevel: 10 },\n]);\nif (!res.ok) return showError(res.error); // outer call-level failure\nconst [item] = res.data;\nif (!item.Success) return showItemError(item.Id, item.Error);\n```\n\n`upgradeLevelsBatch` is the only way to pass `Levels`/`TargetLevel` from the\nSDK — the single `upgradeLevel` call only ever raises by one level per call\n(even though the underlying backend request shape supports a multi-level jump\non the single action too, the SDK doesn't expose it that way). To jump several\nlevels on a single instance in one shot, call the batch method with one entry.\nThe charge is the **sum** of each level's cost in the range, not a single\nlump price for the destination level — see references/data-model.md for the\nformula.\n\n### Batch-upgrade several instances at once\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"sword-1\", Levels: 2 },\n { ItemInstanceID: \"shield-1\" }, // Levels defaults to 1\n { ItemInstanceID: \"bow-1\", FodderInstanceIDs: [\"bow-2\", \"bow-3\"] },\n]);\nif (!res.ok) return showError(res.error);\nfor (const entry of res.data) {\n if (entry.Success) applyOk(entry.Id);\n else showItemError(entry.Id, entry.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that instance's\nupgrade applied. The resource charge across the batch is atomic/merged — if\nthe combined cost can't be paid, every included item comes back\n`Success: false`. Refs are deduped by `ItemInstanceID` server-side, and the\nserver processes at most **50 entries per call** — entries past 50 are\nsilently dropped and don't appear in the results at all, so chunk larger sets\ninto multiple calls yourself. An `ItemInstanceID` containing `.` or `$` is\nrejected per-entry rather than failing the whole batch.\n\n### Read the catalog for display (rarity, tags, upgrade cap)\n\n```ts\nimport type { ItemDefinitions } from \"@idosgames/core\";\n\nfunction findItemDef(defs: ItemDefinitions | undefined, itemID: string) {\n for (const catalog of Object.values(defs?.Catalogs ?? {})) {\n const def = catalog.Items?.[itemID];\n if (def) return def; // first match; see references/data-model.md if itemID isn't unique title-wide\n }\n return undefined;\n}\n\nconst defs = client.data.config.itemDefinitions; // ItemDefinitions | undefined\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\nconst def = inst && findItemDef(defs, inst.ItemID);\ndef?.Metadata?.RarityID; // \"Epic\", etc — drives UI framing\ndef?.Upgrade?.MaxLevel; // upgrade cap for the progress bar\n```\n\n`client.data.config.itemDefinitions` is a dedicated cache getter (not the\ngeneric `getSection` map other modules use) — it returns whichever came in\nlast: a standalone `client.title.getItemDefinitions()` call, or the `Item`\nblock embedded in the full title config from `getTitlePublicConfiguration()`\n(see the title-system skill for both). When you already know an instance's\n`CatalogID`, look it up directly (`defs.Catalogs?.[catalogID]?.Items?.[itemID]`)\ninstead of scanning — it's the same strict-first rule the backend applies, and\navoids the rare same-`ItemID`-in-two-catalogs ambiguity described in\nreferences/data-model.md.\n\n## Gotchas\n\n- **`upgradeLevel` only ever steps +1.** There's no `levels`/`targetLevel`\n option on the single call — use `upgradeLevelsBatch` with one ref for\n multi-level jumps.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`upgrade_item_{instanceID}_{uuid}`), so two separate calls are two real\n operations — a double-clicked \"Upgrade\" can charge twice. Disable the\n control while a call is in flight. (Firing the same endpoint again within\n the throttle window, default 600ms, is rejected with `reason: \"throttled\"`\n rather than duplicated, but don't rely on that for correctness.)\n- **Render from the refreshed inventory, not a locally patched copy.** The\n service re-fetches `GetUserInventory` on success rather than mutating just\n the one instance — treat `client.data.user.state?.InventoryV2` as the\n source of truth after any upgrade call. In particular, the upgraded\n instance's id in the response can differ from the id you called with (see\n the `Quantity`/pristine-pack split note in references/data-model.md).\n- **Fodder valuation is mode-specific, not \"any spare copy is worth 1.\"**\n `Merge` and `InvestmentRefund` value a fodder copy by its own level\n (`W(level)`, growing super-linearly for `Merge`) — a single high-level\n fodder instance can outweigh several low-level ones, or vice versa, and\n `FodderConsumed` only reports burns for these two weighted modes. Under the\n legacy/`FlatCount` mode, fodder isn't weighted at all: the self-item portion\n of the cost is paid through the ordinary resource-consume pipeline (one\n unit of it is implicitly the instance being upgraded), and `FodderConsumed`\n stays empty even if you pass `fodderInstanceIDs`. Don't build a \"fodder\n value\" UI assuming a flat count applies universally — read\n `ItemDefinition.Upgrade.Fodder.ValuationMode` first.\n- **Catalog IDs can self-heal underneath you.** If an item was moved to a\n different catalog after an instance was granted, the resolver falls back to\n a title-wide scan by `ItemID` and — if unambiguous — silently patches the\n instance's stored `CatalogID` to the resolved one as part of the upgrade.\n Read `CatalogID` off the response/refreshed cache, don't cache it\n separately. If the same `ItemID` now exists in two or more catalogs, the\n fallback refuses to guess and the upgrade fails with a \"not found\" error\n even though the instance still nominally exists — that's a config/data\n issue for the title owner, not something to route around client-side.\n- **Stackable items never appear in `UnstackableItems`.** If\n `ItemDefinition.IsStackable` is true, there is no per-instance `Level` to\n upgrade — `upgradeLevel`/`upgradeLevelsBatch` don't apply to it at all\n (attempting it is rejected as \"stackable; levels are only supported on\n unstackable items\").\n- **Equipment truth lives on the instance, not the character.** `EquippedSlot`\n on `UnstackableItemInstanceState` is authoritative; the Character module's\n per-character `Equipment` map is a synced cache view. Upgrading an equipped\n instance also recomputes and patches its owner's `Power` server-side in the\n same atomic write — see the character-system skill for equip/unequip calls\n and the two-sided character/item slot-rule matrix.\n- **Cost objects are the shared `ResourceConsume`/`ResourceGrant` types.**\n `ItemDefinition.Upgrade.PriceOptions` and the response's `Resources`\n field use the same shared resource model as every other module — see the\n currency-system skill for the full breakdown; briefly, `Resources` on the\n response is a `ResourceOperation` (`{ Grant?, Consume? }`) that's already\n been applied to cached balances by the time you read it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every `ItemDefinition`\nfield (stats, equip rules, upgrade/fodder config, NFT binding, metadata), the\ncatalog/root shape and resolution rule, the upgrade cost formula, and the\nfodder valuation/selection formulas transcribed from the backend. Read it when\nbuilding config-driven UI (upgrade cost previews, fodder pickers, rarity\nbadges) or when an error message points at a config rule you need to\nunderstand.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Item data model — reference\n\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\n- [ItemCatalog](#itemcatalog)\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\n- [ItemDefinition](#itemdefinition)\n- [ItemStats](#itemstats)\n- [ItemEquipment](#itemequipment)\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\n- [ItemMetadata](#itemmetadata)\n- [NFTModel](#nftmodel)\n- [Player state: InventoryV2](#player-state-inventoryv2)\n- [Requests, responses, and actions](#requests-responses-and-actions)\n\n---\n\n## Config: ItemDefinitions\n\nRoot container for every item catalog in the title.\n\n```ts\ninterface ItemDefinitions {\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\n}\n```\n\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\nthe exported type is pinned to a hand-written interface instead.\n\n## ItemCatalog\n\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\n\n```ts\ninterface ItemCatalog {\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\n}\n```\n\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\nguaranteed unique **within** a catalog — the same `ItemID` string can\nlegitimately appear in more than one catalog, which is exactly what the\nresolution rule below has to handle.\n\n## Catalog resolution rule\n\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\ncall this yourself, but its behavior explains error messages and a\nself-healing field you'll see on upgrade responses:\n\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\n unambiguous by construction.\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\n (the item was moved to a different catalog since the instance was granted),\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\n that's the resolved definition.\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\n more** catalogs, the resolver refuses to guess and returns nothing (the\n caller reports \"item definition not found\").\n\n**Self-heal:** when resolution succeeds via the fallback path with a\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\none as part of the same atomic write — silently, no separate event. That's why\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\nthe instance before calling upgrade: read it back off the response / refreshed\ncache, don't assume it's unchanged.\n\n---\n\n## ItemDefinition\n\nThe template from which player instances are created.\n\n```ts\ninterface ItemDefinition {\n ItemID: string;\n CatalogID: string;\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\n DisplayName?: string;\n Description?: string;\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\n CustomData?: string;\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\n NFT?: NFTModel; // blockchain binding, if any\n Stats?: ItemStats;\n Equipment?: ItemEquipment;\n Upgrade?: ItemUpgrade;\n Metadata?: ItemMetadata;\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\n}\n```\n\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\nconsumable can still have upgrade tiers, and an equippable item can be\nnon-upgradable.\n\n---\n\n## ItemStats\n\nStat modifiers/Power the item contributes when equipped. Applied in two\nlayers — flat bonuses added to the base stat first, then percent bonuses\nmultiply the (base + flat) total. Consumed by the Character module's Power\ncomputation (see character-system skill) — never recomputed client-side.\n\n```ts\ninterface ItemStats {\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\n}\n```\n\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\n\n---\n\n## ItemEquipment\n\nThe item-side half of the two-sided equip rule matrix (the character-side\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\nreference doc — both must pass for an equip to succeed).\n\n```ts\ninterface ItemEquipment {\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\n UseRequirements?: Record<string, number>; // statID -> required character stat level\n AllowedCharacterIDs?: string[]; // null/empty = any character\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\n}\n```\n\n---\n\n## ItemUpgrade + cost formula\n\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\n`upgradeLevelsBatch`.\n\n```ts\ninterface ItemUpgrade {\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\n}\n```\n\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\nnot the step count):\n\n```\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\n// BaseCost = the Cost of the selected PriceOptions option\n```\n\n— identical semantics to the Character module's stat-cost scaling, and the same shared\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\nfrom `current + 1` through the resolved target — it is not a single jump priced\noff the destination level alone. If every scaled amount rounds to `0`, or the\nselected option is empty, the upgrade is rejected as misconfigured rather than\ntreated as free.\n\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\nfor the first option available on the caller's platform.\n\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\nthrough unchanged and resolved by the shared premium pipeline per level before\nthe per-level bundles are summed — see the currency-system skill for\n`ResourceConsume`'s premium fields.\n\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\nwith `firstStep = 1`, so level 1 is the plain base):\n\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\n before aggregation into the character's total gear-percent.\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\n balance any double-counting themselves via weights).\n\nAn **unset** curve means that quantity does not grow with level — only the base value\napplies at every level. There is no field here whose neutral value is `1`: empty is the\nneutral, always. These three are read-only\ninputs to server computations (Power, PvP stat calc); the SDK never\nrecomputes them for you.\n\n---\n\n## Fodder valuation + selection modes\n\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\nand picked when the upgrade's own cost is expressed in copies of itself. Any\nother cost entries (currencies, other items, event tokens) are charged\nnormally through the regular resource pipeline regardless of `Fodder` config.\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\n`ProtectLeveled` selection.\n\n```ts\ninterface ItemUpgradeFodder {\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\n Selection?:\n \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\" | \"SameLevelOnly\";\n}\n```\n\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\n\nThe server expresses the self-item portion of the upgrade cost as a **target\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\nburns fodder copies until their summed `W(level)` meets or exceeds that\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\n\n| Mode | `W(L)` formula | Notes |\n| ------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\n\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\nclient-side for a cost preview, but the server is what actually enforces\ncoverage.\n\n### Selection — which instances get burned\n\nOnly relevant when the caller doesn't already specify exact fodder for every\nunit needed (or when supply must be chosen automatically):\n\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\n eligible; anything the player has already leveled up is never auto-selected\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\n in the sense that the pool is still filtered this way in `FlatCount` mode\n (where fodder isn't weighted at all — see below).\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\n same-item/same-catalog, not equipped, not expired, not already claimed by\n another item in the same batch) are sorted by `W(level)` ascending, then by\n acquisition time, then by ID, and burned cheapest-first until the target\n value is covered. Leveled copies are eligible here and burn last (they're\n worth more per unit, so they're a poor early pick under this greedy order).\n- **`SameLevelOnly`** — only instances **at the upgraded item's own level**\n are eligible: level 2 is fed by level-1 copies, level 3 by level-2 copies,\n and so on. This is the classic tier merge, and it is the mode you want when\n the design reads \"two of the same tier make one of the next\".\n\n Prefer it over `CheapestFirst` for merge economies. `CheapestFirst` is an\n _order_, not a restriction: once the cheap copies run out it will burn a\n leveled one, and it burns it **whole** (an instance cannot be partially\n consumed), so a copy worth `W(2) = 2` pays a cost of `1` and the remainder\n is destroyed. Under `SameLevelOnly` that copy is not a candidate at all —\n the upgrade is refused instead, with an error naming the level that is\n short. Overshoot is impossible whenever the weight curve is integral,\n because the cost of leaving level `L` is exactly `W(L)` — one copy per\n upgrade.\n\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\n validated individually (must exist, must be the same item/catalog, must not\n be equipped or expired, must not already be claimed elsewhere in the same\n batch) and rejected by name if any check fails. If the combined `W(level)`\n of your supplied instances doesn't cover the target, the whole upgrade is\n rejected — nothing is partially burned.\n\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\nthat mode the self-item cost is settled by the _regular_ resource-consume\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\nrather than being burned), and the rest come from plain inventory count, with\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\nself-item cost entry, or fodder that's a different item, is rejected.\n\n---\n\n## ItemMetadata\n\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\nsubsystem and general UI.\n\n```ts\ninterface ItemMetadata {\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\n CollectionID?: string; // ties the item into a Collection set/album page\n AuthorID?: string; // e.g. UGC/creator attribution\n}\n```\n\n---\n\n## NFTModel\n\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\nitem mirrored on both an EVM chain and Solana).\n\n```ts\ninterface NFTModel {\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\n}\n\ninterface NFTNetworkBinding {\n ContractAddress?: string; // EVM contract or Solana mint address\n TokenID?: string;\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\n}\n```\n\nSee the blockchain-system skill for the wallet/mint/transfer flows that\npopulate and consume this binding.\n\n---\n\n## Player state: InventoryV2\n\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\n`upgradeLevelsBatch` (and other inventory-affecting calls).\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\n RemainingUses?: number; // consumable-with-charges items\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\n AcquiredAt: string;\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\n CustomData?: string | null;\n}\n```\n\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\ncurrency-system skill; they ride along in the same inventory snapshot but\naren't item-related.\n\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\nwhich half of the inventory backs it.\n\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\none copy — e.g. leveling one copy of a stack of five identical swords — the\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\ncome back as a **different** id than the one you called with — always read\nthe instance id off the response (or the refreshed cache), don't assume it's\nunchanged. The backend also opportunistically re-merges pristine fragments of\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\nyou don't need to do anything to trigger or handle that.\n\n---\n\n## Requests, responses, and actions\n\n```ts\ninterface ItemRequest extends BaseRequest {\n ItemInstanceID?: string;\n Levels?: number;\n TargetLevel?: number;\n FodderInstanceIDs?: string[];\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\n Upgrades?: ItemUpgradeRef[];\n}\n```\n\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\n**not** expose them — it only ever raises by one level per call. To move\nseveral levels in one call (on one or many instances), use\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\n FodderInstanceIDs?: string[];\n}\n\ninterface FodderConsumedEntry {\n ItemInstanceID: string;\n Units: number; // how many copies of this instance/pack were burned\n Level: number; // the fodder instance's level at time of consumption\n}\n\ninterface UpgradeItemLevelResponse {\n ServerTimeUtc: string;\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\n ItemID: string;\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\n Level: number; // new level after the upgrade\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\n}\n\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\n```\n\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\nuseful when reading logs/errors that echo the action):\n\n```ts\nconst ItemAction = {\n UpgradeLevel: \"UpgradeLevel\",\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\n} as const;\n```\n\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\nshape used across the whole SDK — see the currency-system skill for the full\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\n`PremiumDiscounts` can reduce a displayed base cost.\n\n### Server-side limits (verified against the backend)\n\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\n appear in the result array at all. Chunk larger sets yourself.\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\n id in the same call only processes once.\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\n (single call fails outright; batch reports it as a failed item).\n- **Atomicity**: both the single and batch charge/patch happen inside one\n Mongo transaction — either the whole thing (cost + level + any fodder burns\n - owner Power recompute) applies, or none of it does.\n- **Idempotency**: the backend replays the same result for a repeated call\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\n server-side reason key, so re-running the _same target level_ twice is safe\n to retry). The TS `upgradeLevel` method, however, mints a fresh\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\n from the SDK's side, two separate calls are always two separate operations;\n see the Gotchas section in SKILL.md.\n"
|
|
8
|
+
"content": "# Item data model — reference\n\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\n- [ItemCatalog](#itemcatalog)\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\n- [ItemDefinition](#itemdefinition)\n- [ItemStats](#itemstats)\n- [ItemEquipment](#itemequipment)\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\n- [ItemMetadata](#itemmetadata)\n- [NFTModel](#nftmodel)\n- [Player state: InventoryV2](#player-state-inventoryv2)\n- [Requests, responses, and actions](#requests-responses-and-actions)\n\n---\n\n## Config: ItemDefinitions\n\nRoot container for every item catalog in the title.\n\n```ts\ninterface ItemDefinitions {\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\n}\n```\n\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\nthe exported type is pinned to a hand-written interface instead.\n\n## ItemCatalog\n\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\n\n```ts\ninterface ItemCatalog {\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\n}\n```\n\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\nguaranteed unique **within** a catalog — the same `ItemID` string can\nlegitimately appear in more than one catalog, which is exactly what the\nresolution rule below has to handle.\n\n## Catalog resolution rule\n\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\ncall this yourself, but its behavior explains error messages and a\nself-healing field you'll see on upgrade responses:\n\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\n unambiguous by construction.\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\n (the item was moved to a different catalog since the instance was granted),\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\n that's the resolved definition.\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\n more** catalogs, the resolver refuses to guess and returns nothing (the\n caller reports \"item definition not found\").\n\n**Self-heal:** when resolution succeeds via the fallback path with a\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\none as part of the same atomic write — silently, no separate event. That's why\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\nthe instance before calling upgrade: read it back off the response / refreshed\ncache, don't assume it's unchanged.\n\n---\n\n## ItemDefinition\n\nThe template from which player instances are created.\n\n```ts\ninterface ItemDefinition {\n ItemID: string;\n CatalogID: string;\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\n DisplayName?: string;\n Description?: string;\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\n CustomData?: Record<string, string>; // arbitrary key/value pairs for fields that don't fit typed blocks\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\n NFT?: NFTModel; // blockchain binding, if any\n Stats?: ItemStats;\n Equipment?: ItemEquipment;\n Upgrade?: ItemUpgrade;\n Metadata?: ItemMetadata;\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\n}\n```\n\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\nconsumable can still have upgrade tiers, and an equippable item can be\nnon-upgradable.\n\n---\n\n## ItemStats\n\nStat modifiers/Power the item contributes when equipped. Applied in two\nlayers — flat bonuses added to the base stat first, then percent bonuses\nmultiply the (base + flat) total. Consumed by the Character module's Power\ncomputation (see character-system skill) — never recomputed client-side.\n\n```ts\ninterface ItemStats {\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\n}\n```\n\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\n\n---\n\n## ItemEquipment\n\nThe item-side half of the two-sided equip rule matrix (the character-side\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\nreference doc — both must pass for an equip to succeed).\n\n```ts\ninterface ItemEquipment {\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\n UseRequirements?: Record<string, number>; // statID -> required character stat level\n AllowedCharacterIDs?: string[]; // null/empty = any character\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\n}\n```\n\n---\n\n## ItemUpgrade + cost formula\n\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\n`upgradeLevelsBatch`.\n\n```ts\ninterface ItemUpgrade {\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\n}\n```\n\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\nnot the step count):\n\n```\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\n// BaseCost = the Cost of the selected PriceOptions option\n```\n\n— identical semantics to the Character module's stat-cost scaling, and the same shared\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\nfrom `current + 1` through the resolved target — it is not a single jump priced\noff the destination level alone. If every scaled amount rounds to `0`, or the\nselected option is empty, the upgrade is rejected as misconfigured rather than\ntreated as free.\n\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\nfor the first option available on the caller's platform.\n\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\nthrough unchanged and resolved by the shared premium pipeline per level before\nthe per-level bundles are summed — see the currency-system skill for\n`ResourceConsume`'s premium fields.\n\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\nwith `firstStep = 1`, so level 1 is the plain base):\n\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\n before aggregation into the character's total gear-percent.\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\n balance any double-counting themselves via weights).\n\nAn **unset** curve means that quantity does not grow with level — only the base value\napplies at every level. There is no field here whose neutral value is `1`: empty is the\nneutral, always. These three are read-only\ninputs to server computations (Power, PvP stat calc); the SDK never\nrecomputes them for you.\n\n---\n\n## Fodder valuation + selection modes\n\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\nand picked when the upgrade's own cost is expressed in copies of itself. Any\nother cost entries (currencies, other items, event tokens) are charged\nnormally through the regular resource pipeline regardless of `Fodder` config.\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\n`ProtectLeveled` selection.\n\n```ts\ninterface ItemUpgradeFodder {\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\n Selection?:\n \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\" | \"SameLevelOnly\";\n}\n```\n\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\n\nThe server expresses the self-item portion of the upgrade cost as a **target\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\nburns fodder copies until their summed `W(level)` meets or exceeds that\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\n\n| Mode | `W(L)` formula | Notes |\n| ------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\n\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\nclient-side for a cost preview, but the server is what actually enforces\ncoverage.\n\n### Selection — which instances get burned\n\nOnly relevant when the caller doesn't already specify exact fodder for every\nunit needed (or when supply must be chosen automatically):\n\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\n eligible; anything the player has already leveled up is never auto-selected\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\n in the sense that the pool is still filtered this way in `FlatCount` mode\n (where fodder isn't weighted at all — see below).\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\n same-item/same-catalog, not equipped, not expired, not already claimed by\n another item in the same batch) are sorted by `W(level)` ascending, then by\n acquisition time, then by ID, and burned cheapest-first until the target\n value is covered. Leveled copies are eligible here and burn last (they're\n worth more per unit, so they're a poor early pick under this greedy order).\n- **`SameLevelOnly`** — only instances **at the upgraded item's own level**\n are eligible: level 2 is fed by level-1 copies, level 3 by level-2 copies,\n and so on. This is the classic tier merge, and it is the mode you want when\n the design reads \"two of the same tier make one of the next\".\n\n Prefer it over `CheapestFirst` for merge economies. `CheapestFirst` is an\n _order_, not a restriction: once the cheap copies run out it will burn a\n leveled one, and it burns it **whole** (an instance cannot be partially\n consumed), so a copy worth `W(2) = 2` pays a cost of `1` and the remainder\n is destroyed. Under `SameLevelOnly` that copy is not a candidate at all —\n the upgrade is refused instead, with an error naming the level that is\n short. Overshoot is impossible whenever the weight curve is integral,\n because the cost of leaving level `L` is exactly `W(L)` — one copy per\n upgrade.\n\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\n validated individually (must exist, must be the same item/catalog, must not\n be equipped or expired, must not already be claimed elsewhere in the same\n batch) and rejected by name if any check fails. If the combined `W(level)`\n of your supplied instances doesn't cover the target, the whole upgrade is\n rejected — nothing is partially burned.\n\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\nthat mode the self-item cost is settled by the _regular_ resource-consume\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\nrather than being burned), and the rest come from plain inventory count, with\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\nself-item cost entry, or fodder that's a different item, is rejected.\n\n---\n\n## ItemMetadata\n\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\nsubsystem and general UI.\n\n```ts\ninterface ItemMetadata {\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\n CollectionID?: string; // ties the item into a Collection set/album page\n AuthorID?: string; // e.g. UGC/creator attribution\n}\n```\n\n---\n\n## NFTModel\n\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\nitem mirrored on both an EVM chain and Solana).\n\n```ts\ninterface NFTModel {\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\n}\n\ninterface NFTNetworkBinding {\n ContractAddress?: string; // EVM contract or Solana mint address\n TokenID?: string;\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\n}\n```\n\nSee the blockchain-system skill for the wallet/mint/transfer flows that\npopulate and consume this binding.\n\n---\n\n## Player state: InventoryV2\n\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\n`upgradeLevelsBatch` (and other inventory-affecting calls).\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\n RemainingUses?: number; // consumable-with-charges items\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\n AcquiredAt: string;\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\n CustomData?: Record<string, string> | null;\n}\n```\n\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\ncurrency-system skill; they ride along in the same inventory snapshot but\naren't item-related.\n\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\nwhich half of the inventory backs it.\n\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\none copy — e.g. leveling one copy of a stack of five identical swords — the\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\ncome back as a **different** id than the one you called with — always read\nthe instance id off the response (or the refreshed cache), don't assume it's\nunchanged. The backend also opportunistically re-merges pristine fragments of\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\nyou don't need to do anything to trigger or handle that.\n\n---\n\n## Requests, responses, and actions\n\n```ts\ninterface ItemRequest extends BaseRequest {\n ItemInstanceID?: string;\n Levels?: number;\n TargetLevel?: number;\n FodderInstanceIDs?: string[];\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\n Upgrades?: ItemUpgradeRef[];\n}\n```\n\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\n**not** expose them — it only ever raises by one level per call. To move\nseveral levels in one call (on one or many instances), use\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\n FodderInstanceIDs?: string[];\n}\n\ninterface FodderConsumedEntry {\n ItemInstanceID: string;\n Units: number; // how many copies of this instance/pack were burned\n Level: number; // the fodder instance's level at time of consumption\n}\n\ninterface UpgradeItemLevelResponse {\n ServerTimeUtc: string;\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\n ItemID: string;\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\n Level: number; // new level after the upgrade\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\n}\n\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\n```\n\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\nuseful when reading logs/errors that echo the action):\n\n```ts\nconst ItemAction = {\n UpgradeLevel: \"UpgradeLevel\",\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\n} as const;\n```\n\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\nshape used across the whole SDK — see the currency-system skill for the full\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\n`PremiumDiscounts` can reduce a displayed base cost.\n\n### Server-side limits (verified against the backend)\n\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\n appear in the result array at all. Chunk larger sets yourself.\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\n id in the same call only processes once.\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\n (single call fails outright; batch reports it as a failed item).\n- **Atomicity**: both the single and batch charge/patch happen inside one\n Mongo transaction — either the whole thing (cost + level + any fodder burns\n - owner Power recompute) applies, or none of it does.\n- **Idempotency**: the backend replays the same result for a repeated call\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\n server-side reason key, so re-running the _same target level_ twice is safe\n to retry). The TS `upgradeLevel` method, however, mints a fresh\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\n from the SDK's side, two separate calls are always two separate operations;\n see the Gotchas section in SKILL.md.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "push-notifications",
|
|
3
|
+
"description": "Add push notifications to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.push (PushService): ask the player for permission from a tap, register the browser's Web Push subscription with the server, draw the right button state, list and remove the player's devices, and ship the service worker that displays a notification. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants push notifications, browser notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a re-engagement or comeback reminder, an energy-refilled or build-finished alert, a service worker, sw.js, VAPID, PushManager, Notification.permission, or otherwise touches client.push, PushService, PushConfigResponse, PushSubscriptionView or PushPermissionState — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: push-notifications\ndescription: >-\n Add push notifications to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.push (PushService): ask the player for\n permission from a tap, register the browser's Web Push subscription with the\n server, draw the right button state, list and remove the player's devices,\n and ship the service worker that displays a notification. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg, voxelcraft) and wants push notifications, browser\n notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a\n re-engagement or comeback reminder, an energy-refilled or build-finished\n alert, a service worker, sw.js, VAPID, PushManager, Notification.permission,\n or otherwise touches client.push, PushService, PushConfigResponse,\n PushSubscriptionView or PushPermissionState — even if they don't name the\n module explicitly.\n---\n\n# Push notifications (iDosGames TS SDK)\n\nPush reaches a player who is **not in the game**. That single fact shapes the\nwhole module: the text is resolved on the server (nobody is around to ask what\nlanguage to use), the sending is done by the backend from a queue, and the only\nthing the game does is get the browser registered and ship a service worker\nthat displays what arrives.\n\nThis skill is for **using** the production `PushService`. A refusal is almost\nalways the browser or the publisher's config, not a bug to work around.\n\n## The one rule that outranks everything else here\n\n**`client.push.subscribe()` runs from a real tap. Never on load, never in a\n`useEffect`, never \"just to check\".**\n\nBrowsers reject `Notification.requestPermission()` outside a user gesture, and\nChrome **permanently blocks an origin** after a few dismissals. So an automatic\nprompt does not merely fail — it takes away the player's ability to ever say\nyes, and nothing in your code can undo that afterwards. Only the person, in\ntheir browser's site settings.\n\nAsk when the player has just been given a reason to want it (\"tell me when my\nenergy is full\"), not when the game starts.\n\n## Drawing the button\n\n```ts\nimport { PushPermissionState } from \"@idosgames/core\";\n\nconst state = await client.push.getState();\n```\n\nFive values, and **four of them mean \"no button\"** — for different reasons:\n\n| State | What it means | What to draw |\n| ------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| `idle` | Available, nobody asked yet | **The button.** This is the only state that gets one |\n| `subscribed` | This browser is registered | \"Notifications on\", plus a way to turn them off |\n| `blocked` | The person said no, or Chrome said it for them | Nothing, or a line explaining that browser settings can undo it. **Do not offer to retry** |\n| `unsupported` | No Push API, or the publisher has not enabled the feature | Nothing at all |\n| `embedded` | Running inside the portal iframe | Nothing — the site owns this, see below |\n\n`unsupported` deliberately does not distinguish \"the platform has no keys\" from\n\"the publisher did not opt in\": in both cases there must be no button, and a\nfeature that cannot work has to be invisible rather than broken.\n\n## Subscribing\n\n```ts\nasync function onEnableNotificationsTapped() {\n const result = await client.push.subscribe();\n if (!result.ok) return showToast(result.error.message);\n\n // \"portal\" means the WEBSITE subscribed on the player's behalf — see below.\n showToast(result.data.Where === \"portal\" ? \"Notifications on\" : \"All set\");\n}\n```\n\nIdempotent: a browser that is already subscribed re-sends the same endpoint,\nwhich refreshes the row's language and timezone instead of creating a second\none. Calling it twice is harmless.\n\n`subscribe()` registers `./sw.js` by default — **relative on purpose**. A hosted\nbuild lives at `cloud.idosgames.com/drive/app/{titleID}/`, so a leading slash\nwould point at another game's folder, and the relative form also scopes the\nregistration to exactly this game's directory. That scope is what gives each\ngame its own subscription on a shared origin. Pass `swPath` only if your worker\nlives somewhere else.\n\n## ⚠⚠ Inside the portal it works differently, and that is not fixable\n\nA game opened on `idosgames.com/app/{id}` runs in a **cross-origin iframe**,\nwhere the browser blocks the permission prompt outright. Not a policy anyone can\nrelax — it is how permissions work.\n\nSo there `subscribe()` asks the **website** to do it, on its own top-level\ndocument. The site creates the row against the player's platform identity, the\n**engine still does the sending**, and the player's game id reaches that row on\ntheir next platform sign-in. `result.data.Where` tells you which happened; a\nportal subscription is real, it just does not belong to the game's origin, and\n`Subscription` comes back `null` because the game never sees its keys.\n\nConsequences for your UI: `getState()` returns `embedded` there, so the button\nis hidden by default. If you want an \"enable notifications\" affordance in the\nportal too, call `subscribe()` from a tap anyway and branch on the result — but\ndo not try to read `Notification.permission` or `pushManager`, which describe\nthe iframe, not the page the player is looking at.\n\n## The service worker\n\n`templates/host-starter/public/sw.js` ships as a starting point you own. Two\nthings in it are not negotiable:\n\n1. **⚠⚠ No `fetch` handler. Ever.** A caching worker looks like a free win and\n is the most expensive mistake available here: a cached `index.html` points at\n content-hashed chunks that the next deploy deletes, and the player gets a\n white screen served from _inside their own browser_, where neither a CDN\n purge nor Ctrl+F5 reaches. The platform already paid for that bug once in the\n publisher dashboard. If you want offline support, do it deliberately and\n never cache the document.\n2. **Always call `showNotification`.** The subscription was created with\n `userVisibleOnly: true`, which is a promise to display every message. Break it\n and the browser first warns the player about background activity, then revokes\n the permission.\n\nThe payload the server sends is small and already localised:\n`{ title, body?, url?, icon?, tag? }`. Do not translate it in the worker — the\ntext was resolved into the **device's** language at send time, using the locale\ncaptured when the subscription was created.\n\n## Managing devices\n\n```ts\nconst list = await client.push.getSubscriptions(); // every device, this title\nawait client.push.unsubscribeByHash(row.EndpointHash); // remove one of them\nawait client.push.unsubscribe(); // remove THIS browser\n```\n\nA subscription belongs to a **(browser, registration)** pair, not to an account:\none person's phone, laptop and portal tab are three separate rows. Clearing site\ndata destroys one silently, with no event — which is why `getState()` asks the\nregistration rather than trusting `Notification.permission`, and why every call\nhere is idempotent.\n\n`unsubscribe()` does both halves — the browser and the server — and both matter:\ndropping only the server row leaves a browser holding a live endpoint, and\ndropping only the browser one leaves a row that keeps being sent to until a push\nservice answers `410`.\n\n## Things not to do\n\n- **Do not compute `EndpointHash`.** It is `sha256(endpoint)` and trivial to\n reproduce — and it addresses a shared database that already has two\n implementations guarded by golden vectors. A third one, in another language\n and without those vectors, drifts silently and leaves the player looking at a\n button in the wrong state. Use the value the server returned.\n- **Do not bake the VAPID key into the build.** It arrives from `getConfig()`\n because it is a platform key shared by both backends; baking it in means a key\n rotation requires rebuilding every game.\n- **Do not poll `getState()`.** Nothing changes it except the player, in a\n prompt you started.\n- **Do not send notifications from the client.** There is no such call and there\n will not be one: producing a notification is a server concern, gated by the\n publisher's per-player and per-title daily caps and quiet hours. The publisher\n API quota does not even see sends — those caps are the only ceiling there is.\n- **Do not ask again after `blocked`.** There is nothing to ask.\n\n## What the publisher controls (and you cannot)\n\nIn the title config, under `Push`: the master switch (**off by default** — this\nspends a real person's attention), a per-player daily cap, a title-wide daily\ncap, quiet hours, and a default icon. Quiet hours are evaluated in the\n**device's** timezone, captured at subscribe time and separate from the\nlanguage — Portuguese is spoken in Lisbon and in São Paulo.\n\nNone of this is visible to the game. A notification that was capped or fell into\nquiet hours simply never arrives, and there is no client-side signal for it.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "store-system",
|
|
3
|
-
"description": "Build a store / shop
|
|
4
|
-
"content": "---\nname: store-system\ndescription: >-\n Build a store / shop system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load storefront and offer\n (SKU) definitions, load the player's purchase counters, and purchase one or\n many offers (currency/item packs, bundles, cosmetics) with virtual/item\n cost. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants a shop/store screen,\n IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise\n touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition,\n or offer purchasing — even if they don't name the module explicitly.\n---\n\n# Store system (iDosGames TS SDK)\n\nThe Store module lets a title sell **offers** (SKUs) — currency packs, item\nbundles, cosmetics, anything priced via `ResourceConsume` — grouped into one or\nmore **storefronts**. Everything is **server-authoritative**: the client asks\nthe backend to purchase, the backend validates cost, rules, and limits, and the\nSDK mirrors the confirmed result (resources + purchase counters) into a local\ncache your UI reads. You never mutate store state yourself — you call a\nmethod, check the result, and render from the cache.\n\nThis skill is for **using** the production `StoreService`, not for porting or\nextending it. If a purchase is rejected, that's the backend enforcing a rule\n(cost, time window, audience gate, purchase cap) — surface the error, don't try\nto reproduce the check client-side.\n\nStore's `Cost`/`Rewards` are virtual (`ResourceConsume`/`ResourceGrant`) —\ncurrency, items, event tokens, premium-tier grants. There is no real-money IAP\nreceipt flow inside this module; that lives entirely in the separate Purchase\nmodule (not covered here).\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n storefronts and offers: which offers live in which store, their `Cost` and\n `Rewards`, and their availability `Rules`. Fetched with `getDefinitions()`.\n2. **User store state** (state, per player) — this player's purchase counters\n per offer (`TotalPurchases`, `DailyPurchases`, reset time). Fetched with\n `getUserState()`.\n\nAn offer is identified by a string `OfferID`; a storefront by `StoreID`. An\noffer can be listed in multiple stores via `StoreIDs`, letting the same SKU\nappear in, say, both the main shop and a limited-time event shop. For the full\nfield-by-field shape of Definitions and state (purchase-limit reset math,\nbatch semantics, special-value rules), 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.\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 store = client.store; // the StoreService\n```\n\nEvery store method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Offer not found\nin the specified store.\", \"Offer is not yet available.\", \"Offer has expired.\",\n\"Offer is not available for you.\", \"Purchase limit reached for offer\n'<id>'. Max: <n>.\", \"Daily purchase limit reached for offer '<id>'. Max per\nday: <n>.\", or an `ApplyResourceOperationAtomicAsync` failure such as\ninsufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's store/offer catalog (config). | `StoreDefinitions` |\n| `getUserState()` | Load this player's purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. `options` = `{ selectedOptionID?, payment? }`. | `StorePurchaseResponse` (`Resources`) |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` (`BatchItemResult<StorePurchaseResponse>[]`) |\n\n`purchase` clamps `count` server-side to the range **1–100** (values ≤0 sent by\na caller are floored to 1 by the backend, but the SDK itself already rejects\n`count < 1` client-side as `reason: \"client\"`). `purchaseBatch` takes\n`StorePurchaseRef[]`: `{ OfferID, Count }[]` — deduped by `OfferID` (one entry\nper offer per call; use `Count` for multiple units), each `Count` clamped to\n1–100, and the list itself clamped to **50 refs per call** (entries past 50 are\nsilently dropped server-side — chunk larger sets yourself).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `purchase`/`purchaseBatch`\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\nsee the currency-system skill for the full `ResourceConsume`/`ResourceGrant`\nreference) to the cached currency/item balances, and bump the purchased\noffer's counters (`TotalPurchases`, `DailyPurchases`, `DailyResetUtc`). Read\nupdated balances and counters straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { StoreDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst offer = defs?.StoreOffers?.[\"pack1\"];\noffer?.PriceOptions; // ways to pay; render with client.checkout.availableOptions(...)\noffer?.Rewards; // ResourceGrant — what it grants\noffer?.Rules; // time window, Gate (SegmentGate), Limits (LimitSpec)\n\n// Purchase counters (only present after getUserState() or a purchase):\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\npurchases[\"pack1\"]?.TotalPurchases;\npurchases[\"pack1\"]?.DailyPurchases;\npurchases[\"pack1\"]?.DailyResetUtc; // ISO — next UTC-midnight reset\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"store:offerPurchased\", (r) => {\n console.log(`Bought ${r.Count}x ${r.OfferID}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show a storefront with purchase-limit UI\n\n```ts\nawait client.store.getDefinitions();\nawait client.store.getUserState();\n\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\n\nconst offersInMainStore = Object.values(defs?.StoreOffers ?? {}).filter((o) =>\n o.StoreIDs?.includes(\"main\"),\n);\n\nfor (const offer of offersInMainStore) {\n const counters = purchases[offer.OfferID];\n const limits = offer.Rules?.Limits;\n const totalLeft =\n limits?.TotalCap && limits.TotalCap > 0\n ? Math.max(0, limits.TotalCap - (counters?.TotalPurchases ?? 0))\n : null; // null = no lifetime cap\n const dailyLeft =\n limits?.DailyCap && limits.DailyCap > 0\n ? Math.max(0, limits.DailyCap - (counters?.DailyPurchases ?? 0))\n : null; // null = no daily cap\n // Disable the buy button when totalLeft === 0 or dailyLeft === 0.\n // Don't try to predict the daily reset instant yourself beyond display —\n // read counters.DailyResetUtc fresh after each purchase/getUserState().\n}\n```\n\n`Rules` (time window + `Gate` audience + `Limits` purchase caps) are enforced\nserver-side — use them client-side only to pre-filter/gray out what you\nalready know will be rejected, not as the source of truth.\n\n### Purchase an offer\n\n```ts\nconst res = await client.store.purchase(\"pack1\", 1);\nif (!res.ok) return showError(res.error); // e.g. \"Purchase limit reached...\", can't afford\n// cache now has updated balances + counters. UI re-renders from cache.\nres.data.Resources; // ResourceOperation actually applied (Consume + Grant)\n```\n\nWhen the offer has several ways to pay, render them with\n`client.checkout.availableOptions(offer.PriceOptions)` and pass the chosen one.\nAn option paid in a store needs the receipt too:\n\n```ts\nawait client.store.purchase(\"pack1\", 1, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"pack1\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call ran;\neach element's `Success`/`Error` tells you whether that item applied. Offers\nrejected on their own merits (unknown id, outside window, gate failed, limit\nreached) are filtered out _before_ the merged charge is built and simply\nreport their own reason — they never affect other items in the batch. The\nremaining, valid offers are then charged as **one merged, all-or-nothing\ntransaction**: if the combined cost can't be paid, every one of those\nsurvivors comes back `Success: false` with an \"Atomic batch purchase failed\"\nerror, even though each was individually valid.\n\n### Cosmetic/bundle offer with only item rewards\n\nNothing offer-specific to do differently — `Rewards` is a `ResourceGrant` like\nany other, so an offer that only grants items (no currency) works through the\nsame `purchase()` call. Read the granted item instances back from\n`client.data.user.state?.InventoryV2` (see the item-system skill) after the\ncall, or from `res.data.Resources.Grant`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` call mints a fresh\n idempotency key (`store_buy_{offerID}_{userID}_{uuid}` client-side, further\n wrapped server-side), so two separate calls are two real operations — a\n double-clicked \"Buy\" can charge twice. Disable the control while a call is\n 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- **`Cost` and `Rewards` can be discounted/boosted server-side.** `Cost` is a\n `ResourceConsume` (may carry `PremiumDiscounts`) and `Rewards` is a\n `ResourceGrant` (may carry `PremiumTiers`) — the backend auto-applies the\n player's best subscription tier (see the premium-system skill). Don't assume\n the displayed base price/reward equals what's actually charged/granted; read\n the actual amounts off `res.data.Resources`.\n- **`count` scales cost and rewards linearly, then premium is applied once.**\n Buying `count=3` multiplies every `Cost`/`Rewards` entry (including event\n tokens) by 3 before premium discounts/bonuses are resolved — it is not 3\n independent purchases, so per-purchase minimums/rounding don't compound.\n- **Only one `Resources` apply per batch call, but every item's own data is\n still correct.** `purchaseBatch` applies the first successful item's\n `Resources` to the cache (the batch charge is merged server-side into one\n operation, so attaching it to every item would double-count balances); the\n per-item `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc` are still correct\n for each offer and drive the purchase-counter patch for every successful\n item, not just the first.\n- **`DailyPurchases` resets on UTC midnight, compared as ISO strings.** The SDK\n mirrors the server's reset logic locally when patching after a purchase\n (`state.DailyResetUtc` becomes the next UTC midnight after\n `ServerTimeUtc`) — you don't need to compute it, just read\n `DailyResetUtc`/`DailyPurchases` from the cache after the call.\n- **Purchase-history writes are best-effort and don't affect the result.** The\n backend appends an audit-log row after a successful purchase; if that write\n fails it's swallowed silently and never surfaces to the client — don't\n expect a Store endpoint to expose purchase history.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the purchase-limit/reset rule matrix, batch all-or-nothing semantics,\nand special-value conventions. Read it when building config-driven UI (cap\npreviews, cooldown countdowns) or when an error message points at a config\nrule you need to understand.\n",
|
|
3
|
+
"description": "Build a store / shop screen in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load the RESOLVED storefront for the current player (rotating daily shops, sections, slots, badges, refresh timers, remaining limits), load the offer catalogue, and purchase one or many offers. Prices can be virtual currency, crypto, a real money store product, a rewarded-video ad credit, or free. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or otherwise touches client.store, StoreService, GetStorefrontResponse, StoreDefinitions or offer purchasing — even if they don't name the module.",
|
|
4
|
+
"content": "---\nname: store-system\ndescription: >-\n Build a store / shop screen in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load the RESOLVED\n storefront for the current player (rotating daily shops, sections, slots,\n badges, refresh timers, remaining limits), load the offer catalogue, and\n purchase one or many offers. Prices can be virtual currency, crypto, a real\n money store product, a rewarded-video ad credit, or free. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem\n packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or\n otherwise touches client.store, StoreService, GetStorefrontResponse,\n StoreDefinitions or offer purchasing — even if they don't name the module.\n---\n\n# Store system (iDosGames TS SDK)\n\nA title sells **offers** (products) placed on **shelves**. The shop is a tree:\n\n```\nStore (storefront)\n└── Layout.Sections\n └── Slots ← a slot is a PLACE on the shelf\n └── the slot decides which offer stands in it\n```\n\n⚠⚠ **A slot is the only address a product can be bought through.** An offer\nlives in a flat catalogue (`StoreOffers`) and becomes buyable only by standing\nin a slot; a product placed in no slot **cannot be bought at all**. There is no\n\"list of stores\" on the product — that field was removed, because it was never\na right to buy: the purchase never checked it.\n\nEverything is **server-authoritative**. The client asks to purchase, the\nbackend validates price, windows, gates, limits **and the rotation roll**, then\nthe SDK mirrors the confirmed result into a local cache your UI reads.\n\n## ⚠ Draw `getStorefront()`, not `getDefinitions()`\n\nThis is the single most important rule of the module.\n\n`getDefinitions()` returns the **config** — identical for every player.\n`getStorefront()` returns the shop **resolved for this player**: rotation\nalready rolled, gates applied, badges and refresh timers computed, remaining\nlimits filled in. None of that can be derived on the client:\n\n- **Rotation** is recomputed by the server on every read and stored nowhere. A\n client rolling it itself would show two devices two different shops.\n- **Counters** (`PurchasedTotal`, remaining caps, cooldown) belong to the\n player and are not in the config.\n- **Gates** filter storefronts, sections, slots and pool entries per player.\n\nUse `getDefinitions()` only for catalogue-wide tooling (an admin view, a\nsearch). A shop screen never needs it.\n\n## Prices: five kinds, one shape\n\nPrice is always a `PriceOptions` dictionary (`Pricing.Options`), the\nplatform-wide standard. An option's cost can be:\n\n| Kind | What it is | On the storefront view |\n| --------------------------------------- | ------------------------------------------- | ---------------------- |\n| Virtual currency / items / event tokens | ordinary `ResourceConsume` | — |\n| Crypto | a crypto currency entry | — |\n| Real money | a store product entry; needs a receipt | `IsStorePaid: true` |\n| Rewarded video | an ad-credit entry, spent like any resource | `IsAdPaid: true` |\n| **Free** | **no cost at all** | `IsFree: true` |\n\n⚠ **An unset cost means FREE, and that is a legal product** — not an unfilled\nprice. A free offer must be limited on some axis (`TotalCap`, `DailyCap`,\n`CooldownSeconds`, or the slot's `PerInstanceCap`); without a limit the server\nrefuses the purchase, because it would be a button that grants itself forever.\n\n⚠ Ad-paid offers spend a **credit the player already earned** by watching a\nvideo (the Advertising module grants it server-side). Read the balance from\n`storefront.AdCreditBalance` to decide whether a card shows \"Watch\" or \"Buy\".\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 store = client.store; // the StoreService\n```\n\nEvery store method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok`.\n\n| Method | Purpose | `data` on success |\n| ------------------------------------- | ---------------------------------------- | ----------------------- |\n| `getStorefront()` | **The shop as this player sees it now.** | `GetStorefrontResponse` |\n| `getDefinitions()` | The raw catalogue (config). | `StoreDefinitions` |\n| `getUserState()` | Purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` |\n\n`options` = `{ selectedOptionID?, payment?, slot? }`.\n\n`purchase` clamps `count` server-side to **1–100**. `purchaseBatch` takes\n`StorePurchaseRef[]`, deduped by `OfferID`, clamped to **50 refs per call**.\n\n## Drawing a shop screen\n\n```ts\nconst res = await client.store.getStorefront();\nif (!res.ok) return showError(res.error);\nconst front = res.data;\n\nfor (const store of front.Stores ?? []) {\n for (const section of store.Sections ?? []) {\n for (const slot of section.Slots ?? []) {\n // ⚠ An EMPTY slot arrives with Offer === null and must still be drawn as\n // an empty frame. Skipping it makes the shelf jump around whenever a\n // window closes or a gate filters the pool — which is exactly why the\n // server sends the slot rather than omitting it.\n if (!slot.Offer) {\n drawEmptyCard(slot.RefreshesAtUtc); // \"opens in ...\"\n continue;\n }\n\n const offer = slot.Offer;\n const state = offer.State;\n\n drawCard({\n title: offer.Identity?.DisplayName, // localization key OR a literal\n ribbons: offer.Identity?.Tags, // static badges: best_value, x2 ...\n // Server-computed badges — do not recompute these:\n firstPurchase: state?.IsFirstPurchaseAvailable,\n soldOut: state?.SoldOut,\n readyAt: state?.AvailableAtUtc, // free-slot cooldown\n // Rotation timer: \"Refreshes in 15h 40m\"\n refreshesAt: slot.RefreshesAtUtc,\n remainingThisWindow: slot.RemainingThisRotation, // null = unlimited\n prices: offer.PriceOptions, // already filtered to this platform\n });\n }\n }\n}\n```\n\n⚠ **Count timers down from `front.ServerTimeUtc`, not from the device clock.**\nThat is what it is for; a player with a skewed clock otherwise sees a shop that\nrefreshes at the wrong moment or claims to have already refreshed.\n\n⚠ **`null` and `0` mean different things in every remaining-count field.**\n`null` = \"no limit on this axis\"; `0` = \"spent, sold out\". Treating `null` as\nzero grays out every unlimited product on the shelf.\n\n`client.store.nextRefreshAtUtc()` returns the nearest refresh across all\nvisible slots — use it to schedule one reload instead of a polling loop.\n\n## Purchasing\n\n```ts\nconst res = await client.store.purchase(offer.OfferID, 1, {\n selectedOptionID: option.OptionID,\n // Pass the slot the player tapped — the storefront gives you all three ids.\n slot: {\n storeID: store.StoreID,\n sectionID: section.SectionID,\n slotID: slot.SlotID,\n },\n});\nif (!res.ok) return showError(res.error);\n```\n\nAn option paid in a store needs the receipt as well:\n\n```ts\nawait client.store.purchase(\"gems_l\", 1, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n⚠ **The slot address is optional as a whole** (all three or none): without it\nthe server finds the slot itself. The roll is re-checked either way, so a\nproduct that rolled for the player nowhere cannot be bought with or without an\naddress. Pass it anyway when you have it — for a pooled product it removes the\nambiguity of which slot's per-window cap applies.\n\n### ⚠ `OFFER_NOT_IN_ROTATION` means \"reload the shop\", not \"show an error\"\n\nThe rotation roll is recomputed, never stored, so editing a slot's pool\nreshuffles the shelf for everyone who has it open. A player who taps a card\nthat is no longer in their roll gets exactly this error:\n\n```ts\nimport { OFFER_NOT_IN_ROTATION } from \"@idosgames/core\";\n\nif (!res.ok && res.error === OFFER_NOT_IN_ROTATION) {\n await client.store.getStorefront(); // silently redraw; do not toast\n return;\n}\n```\n\nAlready-completed purchases are never undone by a reshuffle — counters are\naddressed by product and slot, not by the roll.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:storefrontLoaded` → `GetStorefrontResponse`\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write.\n\n## Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) applyOk(item.Id);\n else showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` says the call ran, each\nelement's `Success`/`Error` says whether that item applied. Items rejected on\ntheir own merits (unknown id, outside window, gate failed, limit reached, not\nin rotation) are filtered out _before_ the merged charge is built. The\nsurvivors are then charged as **one merged, all-or-nothing transaction**.\n\n⚠ An option paid in a **store** cannot go in a batch — one receipt pays for one\npurchase. Ad credits can: a credit is an ordinary resource and two of them add\nup like two coins.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` mints a fresh idempotency\n key, so two calls are two real operations — a double-clicked \"Buy\" charges\n twice. Disable the control while a call is in flight.\n- **The struck-through price is display only.** `CompareAtCost` never reaches\n the charge. `DiscountPercent` is computed by the server and is `null` when it\n cannot be computed (a mixed bundle has no single percentage) — `null` is not\n zero, and printing \"0%\" there is a lie.\n- **Prices and rewards can be adjusted server-side.** Premium tiers apply\n discounts and bonuses automatically; read what was actually charged and\n granted off `res.data.Resources`, not off the displayed base values.\n- **`count` scales cost and rewards linearly, then premium is applied once** —\n it is not N independent purchases.\n- **Only one `Resources` apply per batch call**, but every item's own\n `OfferID`/`Count`/`ServerTimeUtc` is correct and drives its counter patch.\n- **`DailyPurchases` resets on UTC midnight.** Read `DailyResetUtc` from the\n cache after a call rather than computing it.\n- **Tags are for the client; player-dependent badges are not.** Anything whose\n truth depends on the player — first purchase, sold out, a timer — arrives in\n `State`, computed by the server. Do not encode those as tags.\n- **`DisplayName` is a localization key OR a literal.** Resolution is\n `t(x) = own table → fallback table → x itself`, so a title with literal\n names works with no migration. Run these through the localization module.\n- **Render from the cache, handle the error from the result.** Use `reason` to\n decide behaviour (retry on `\"connection\"`, re-auth on `\"unauthorized\"`, toast\n the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstorefront field, the rotation modes, the purchase-limit/reset rule matrix, and\nbatch semantics. Read it when building config-driven UI or when an error points\nat a rule you need to understand.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Store data model — reference\n\nFull shape of the config (Definitions) and player state, the purchase-limit\nrule matrix, batch all-or-nothing semantics, and special-value conventions.\nAll of these are **strictly typed in the SDK** — `StoreDefinitions` and every\nnested block (`StoreDefinition`, `StoreRules`, `StoreOfferDefinition`,\n`StoreOfferRules`) are exported from `@idosgames/core`, so `getDefinitions()`\nand `getSection<StoreDefinitions>(\"Store\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: StoreDefinitions](#config-storedefinitions) — what `getDefinitions()` returns\n- [StoreDefinition (storefront)](#storedefinition-storefront)\n- [StoreOfferDefinition (SKU)](#storeofferdefinition-sku)\n- [Purchase-limit rule matrix](#purchase-limit-rule-matrix)\n- [Purchase flow, scaling, and idempotency](#purchase-flow-scaling-and-idempotency)\n- [Batch purchase semantics](#batch-purchase-semantics)\n- [Special values](#special-values)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Purchases?: Record<string, StorePurchaseState> }`\nand cached at `client.data.user.state?.Store?.Purchases`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/UserStoreState.cs`.\n\n```ts\ninterface StorePurchaseState {\n OfferID: string;\n TotalPurchases: number; // lifetime count, all-time\n DailyPurchases: number; // count since the last DailyResetUtc\n DailyResetUtc: string; // ISO — the next instant DailyPurchases resets to 0\n LastPurchasedAt: string; // ISO — server time of the last successful purchase\n}\n```\n\nThis is a **rate-limit counter store**, not a purchase-history log — it only\nholds what's needed to enforce `TotalCap`/`DailyCap` atomically. A separate\n`StorePurchaseHistoryDocument` audit-log collection exists server-side\n(`UserID`, `TitleID`, `OfferID`, `Count`, `Resources`, `PurchasedAt`) but it is\n**not exposed through any Store endpoint** — there is no \"purchase history\"\nclient call.\n\nA player with no purchase for a given `OfferID` simply has no entry in\n`Purchases` — treat a missing key as `TotalPurchases: 0`, `DailyPurchases: 0`,\nno active daily window.\n\n---\n\n## Config: StoreDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<StoreDefinitions>(\"Store\")`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreDefinitions {\n Stores?: Record<string, StoreDefinition> | null; // key = StoreID\n StoreOffers?: Record<string, StoreOfferDefinition> | null; // key = OfferID\n}\n```\n\nStorefronts and offers are deliberately separate catalogs: an offer references\nthe storefronts it appears in via `StoreIDs`, so the same SKU (price, rewards,\n`OfferID`, analytics) can be reused across the main shop, an event shop, a VIP\nshop, etc. without duplication.\n\n---\n\n## StoreDefinition (storefront)\n\nA logical shop screen (main / event / VIP). Does not embed offers.\n\n```ts\ninterface StoreDefinition {\n StoreID: string; // stable id; never rename after publication — offers reference it\n Type?: string; // segmentation/grouping tag, free-form\n Name?: string; // display name; optional for internal storefronts\n Description?: string;\n Rules?: StoreRules;\n AssetPaths?: Record<string, string>; // banner/icon/background, key = asset slug\n}\n\ninterface StoreRules {\n StartUtc?: string; // storefront opens at this UTC instant; absent = available from the start\n EndUtc?: string; // storefront closes at this UTC instant; absent = no expiration\n RequiredFlags?: string[]; // ALL must be set on the player for the storefront to show\n}\n```\n\n`StoreRules.RequiredFlags` is **not enforced by the `Store.Purchase` /\n`PurchaseBatch` endpoints** — the backend's purchase path (`Store.cs`) only\nvalidates the _offer's_ own `Rules` (window, `Gate`, `Limits`); it never looks\nup which storefront the purchase came through. Treat `StoreRules` purely as\nclient-side \"should I show this storefront\" filtering data, not as a\nserver-enforced purchase gate — the offer-level `Gate`/window/limits are the\nactual enforcement.\n\n---\n\n## StoreOfferDefinition (SKU)\n\nThe purchasable unit. Backend field-level docs from\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreOfferDefinition {\n OfferID: string; // stable id; used in analytics/purchase logs; never rename after publication\n StoreIDs?: string[]; // storefronts this offer appears in; empty/null = invisible everywhere\n Name?: string;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; see the checkout-system skill\n Rewards?: ResourceGrant; // grant-only; see currency-system skill for the shared shape\n Rules?: StoreOfferRules;\n AssetPaths?: Record<string, string>;\n}\n\n\nEvery price in this module is a **`PriceOptions` dictionary** (the platform-wide\nshape, see the `checkout-system` skill): the key is the `OptionID`, one option is\none way to pay, and the entries inside an option's `Cost` are charged together.\n`purchase()` takes the chosen id as `options.selectedOptionID`; omit it and the\nserver takes the first option available on the caller's platform, so a\nsingle-price offer needs no client change. An option whose `Cost` holds a\n`Purchase` entry is paid **in a store** — buy the product and pass the receipt as\n`options.payment`.\n\ninterface StoreOfferRules {\n StartUtc?: string; // offer becomes purchasable at this UTC instant; absent = from the start\n EndUtc?: string; // offer stops being purchasable at this UTC instant; absent = no expiration\n Gate?: SegmentGate; // \"who can buy this\" — premium tier/ID, segment, level, country, recency, experiment\n Limits?: LimitSpec; // purchase caps — see the matrix below\n}\n```\n\n`Gate` is the shared `SegmentGate` (Core/Segment) — all conditions AND-ed, an\nabsent/empty gate means available to everyone. Resolved server-side by\n`SegmentGateEvaluator.Passes` against the player's document at the moment of\npurchase (`Store.cs` line ~170: `\"Offer is not available for you.\"` on\nfailure).\n\n**Shape validation** (`StoreHelpers.ValidateOfferShape`, always run before a\npurchase is accepted): an offer with an empty `Cost` (no `Standard.Entries` and\nno `Standard.EventTokens`) fails with `\"Offer cost is empty.\"`; an offer with\nno `Rewards` at all (`Standard.Entries`, `Standard.EventTokens`, and\n`PremiumTiers` all empty) fails with `\"Offer rewards are empty.\"`. In other\nwords: **every real offer must both cost something and grant something** —\nthere is no free-claim or cost-only shape for Store offers (use the Reward or\nDealOffer module for pure-claim mechanics).\n\n---\n\n## Purchase-limit rule matrix\n\n`LimitSpec` (shared `Core/Limits` type; full field list in\n`packages/core/src/models/_shared/LimitModels.ts`) is reused across the SDK,\nbut Store's enforcement (`StoreHelpers.CheckPurchaseLimits` and\n`BuildPurchaseCounterPatches`, in\n`IDosGamesSDK/API/Client/v2/Store/Services/StoreHelpers.cs`) only reads two of\nits axes:\n\n| `LimitSpec` field | Meaning for Store | Enforcement |\n| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `TotalCap` | Lifetime purchase cap for the offer, summed over `Count` across all purchases | `TotalPurchases + count > TotalCap` → `\"Purchase limit reached for offer '<id>'. Max: <n>.\"` |\n| `DailyCap` | Per-UTC-day purchase cap | `DailyPurchases + count > DailyCap` (only while `now < DailyResetUtc`; otherwise treated as 0) → `\"Daily purchase limit reached for offer '<id>'. Max per day: <n>.\"` |\n\nOther `LimitSpec` axes (`DailyWeightCap`, `PerActivationCap`,\n`CooldownSeconds`, `MaxPerWindow`, `WindowSeconds`) exist on the shared type\nfor other modules but **Store does not read them** — configuring them on a\nStore offer's `Rules.Limits` has no effect on purchase behavior.\n\n**Daily reset timing.** `DailyResetUtc` is set to `now.Date.AddDays(1)` (the\nUTC midnight _after_ the purchase that (re)started the window) the first time\nan offer is bought, or whenever `now >= DailyResetUtc` on a subsequent\npurchase — i.e. the daily window is lazily rolled forward on the next\npurchase attempt, not on a schedule. If a player buys at 23:59 UTC and again\nat 00:01 UTC, the second purchase sees `now >= DailyResetUtc` from the first,\nresets `DailyPurchases` to the new `count`, and pushes `DailyResetUtc` to the\nfollowing midnight.\n\n**Race protection.** The fail-fast check in `CheckPurchaseLimits` runs before\nthe atomic write, but the real guarantee against concurrent double-spends past\nthe cap is an `extraFilter` attached to the same Mongo update\n(`BuildPurchaseCounterPatches`): the write only commits if\n`TotalPurchases <= TotalCap - count` (and the daily equivalent, tolerant of an\nexpired window) still holds at write time. If two concurrent requests would\nboth push a counter over its cap, only one commits — the loser's whole\n`ApplyResourceOperationAtomicAsync` call fails and the purchase is rejected,\nresources untouched.\n\n---\n\n## Purchase flow, scaling, and idempotency\n\nOrder of checks in `Store.StorePurchase` (`Store.cs`), all before any resource\nmutation:\n\n1. `OfferID` required; `count` clamped to **1–100**.\n2. Offer looked up by `OfferID` (optionally filtered by `storeID`, unused by\n the public `Purchase` action) — `\"Offer not found in the specified store.\"`\n if missing.\n3. Window check (`StartUtc`/`EndUtc`) — `\"Offer is not yet available.\"` /\n `\"Offer has expired.\"`.\n4. Shape check (`Cost` non-empty, `Rewards` non-empty) — see above.\n5. Player document read (single read, id/`InventoryV2`/`EventToken`/`Premium`/`Store` projection only).\n6. `Gate` check — `\"Offer is not available for you.\"`.\n7. Limit check (`CheckPurchaseLimits`) — see the matrix above.\n8. **Scaling**: `Cost` and `Rewards` are each scaled by `count` — every\n `ResourceEntry.Amount` and every `EventTokenOperation.Amount` is multiplied\n by `count` (a fresh object; the config definition itself is never mutated).\n `PremiumDiscounts`/`PremiumTiers` percentages are **not** scaled by count —\n only flat amounts are.\n9. The scaled `Cost`/`Rewards` become one `ResourceOperation { Grant, Consume }`\n applied via `ResourceService.ApplyResourceOperationAtomicAsync`, alongside\n the purchase-counter patches from step 7 and a `FeatureUsage` touch (see\n below), under one Mongo transaction with the `extraFilter` guard.\n10. On success, a best-effort audit-log row is appended\n (`StoreHelpers.AppendPurchaseHistoryAsync`) — failures here are swallowed\n and never affect the client response.\n\n**Idempotency.** The reason key is\n`\"StoreBuy:\" + ResourceService.ResolveRelatedEntityID(relatedEntityID, \"store_buy_{offerID}_{userID}\")`.\nThe SDK's `purchase()` always supplies a fresh, unique `RelatedEntityID`\n(`store_buy_{offerID}_{userID}_{uuid}`) per call — so from the client's\nperspective **every `purchase()` call is a brand-new charge**; the idempotency\nkey only protects against the transport layer's own internal retries within a\nsingle logical call, not against you calling `purchase()` twice.\n\n**`FeatureUsage` touch.** Every successful `Purchase` (regardless of `count`)\nincrements a `FeatureIDs.Store` usage touch exactly once — this is \"the player\nengaged the store,\" unrelated to and not a substitute for the per-offer\n`TotalPurchases`/`DailyPurchases` counters.\n\n---\n\n## Batch purchase semantics\n\n`PurchaseBatch` (`Store.PurchaseBatch` in `Store.cs`) trades N round-trips for\none, but keeps per-offer validation independent from the shared charge:\n\n**1. Normalization** — for each `StorePurchaseRef` in `args.Purchases`:\nblank/whitespace `OfferID` is dropped; `OfferID` is trimmed; duplicates by\n`OfferID` are dropped (first occurrence wins — **one offer per batch call**;\nuse `Count` for multiple units of the same offer, not repeated refs);\n`Count <= 0` is treated as `1`, then clamped to **1–100**; the list stops\ngrowing once it reaches `BatchSupport.MaxBatchSize` = **50** — refs beyond the\n50th are silently dropped and never appear in the result at all. An\nall-empty/invalid request (0 refs survive normalization) fails outright with\n`\"Purchases is required\"`.\n\n**2. One player read** for the whole batch (not per-offer).\n\n**3. Per-offer validation, outside the transaction** — for each surviving\n`(offerID, count)`, in order: offer exists → window → shape → `Gate` → purchase\nlimits (same checks and same error strings as the single-purchase path,\nkeyed per offer). Any failure here produces an immediate `BatchItemResult`\nwith `Success: false` and that specific `Error`, and **excludes the offer from\nthe merged charge** — it does not abort the batch.\n\nIf **zero** offers survive this stage, the call returns `Ok` with only the\nper-offer failure results (no atomic transaction is attempted).\n\n**4. Merge + one atomic charge** — for every surviving offer: `Cost`/`Rewards`\nare scaled by that offer's own `count`, then premium discounts/tiers are\nresolved and flattened per-offer (`ResourceService.FilterByPremium`) _before_\nmerging, so each offer's own premium tier is applied — the merge does not\ncreate a single blended discount. The flattened bundles from every surviving\noffer are summed into one `ResourceGrant`/`ResourceConsume`, together with the\npurchase-counter patches for every surviving offer and one shared\n`FeatureUsage` touch, and applied as a **single**\n`ApplyResourceOperationAtomicAsync` call with a combined `extraFilter`\n(AND of every offer's own race-protection filter).\n\n**All-or-nothing across survivors.** If the merged charge fails (e.g. can't\nafford the combined cost, or any one offer's `extraFilter` no longer holds),\n**every surviving offer** — even ones that were individually valid — comes\nback `Success: false` with `\"Atomic batch purchase failed: <reason>\"`. There is\nno partial application within the merged group; only the pre-filtered\nindividually-invalid offers were ever excluded.\n\n**5. Result shape and `Resources` placement.** The merged `ResourceOperation`\nreturned by the atomic call is attached to `Data.Resources` on **only the\nfirst successful item** in call order; every other successful item gets\n`Data.Resources = new ResourceOperation()` (empty, not null) — so summing\n`Resources` across all successful items double-counts nothing, but reading a\nnon-first item's `Resources` for balance info will show nothing. Read balances\nfrom the cache (which the SDK patches once per successful item's own\n`OfferID`/`Count`, so counters are correct for every item) rather than from\neach item's own `Resources`. `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc`\nare correct and independent for every successful item regardless of where\n`Resources` landed.\n\n**Reason key.** `BatchSupport.BuildBatchReason(\"StoreBuyBatch\", relatedEntityID, includedOfferIDs)`\n— one idempotency key covering the whole merged transaction, not one per\noffer.\n\n**Audit log.** On a successful merged charge, one best-effort history row is\nappended per surviving offer (same swallow-on-failure semantics as the single\npath).\n\n---\n\n## Special values\n\n- `Rules` absent entirely on a storefront or offer ⇒ no restriction on that\n axis (always visible / always purchasable / no gate / no limits).\n- `LimitSpec.TotalCap` / `DailyCap` `<= 0` (including absent, which the config\n default `LimitSpec` treats as `0`) ⇒ **unlimited** on that axis — the check\n is skipped entirely, not \"zero purchases allowed.\"\n- `StoreOfferDefinition.StoreIDs` empty or `null` ⇒ the offer exists in the\n catalog but is invisible in every storefront (it can still theoretically be\n purchased by `OfferID` directly, since `Purchase`'s `storeID` filter is\n unused by the public action — but there is no supported storefront UI path\n to reach it).\n- A player with no `Purchases[offerID]` entry is equivalent to\n `TotalPurchases: 0, DailyPurchases: 0`, with no active daily window (the\n `DailyExpired` check treats a missing state the same as an expired one).\n"
|
|
8
|
+
"content": "# Store data model — reference\n\nFull shape of the resolved storefront, the config, and player state; the\nrotation rules; the purchase-limit matrix; and batch semantics. Everything here\nis **strictly typed in the SDK** — the schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\n## Contents\n\n- [The shape of the shop](#the-shape-of-the-shop)\n- [Storefront: what `getStorefront()` returns](#storefront-what-getstorefront-returns)\n- [Rotation](#rotation)\n- [Config: StoreDefinitions](#config-storedefinitions)\n- [Player state](#player-state)\n- [Purchase-limit rule matrix](#purchase-limit-rule-matrix)\n- [Purchase flow, addressing, and idempotency](#purchase-flow-addressing-and-idempotency)\n- [Batch purchase semantics](#batch-purchase-semantics)\n- [Special values](#special-values)\n\n---\n\n## The shape of the shop\n\n```\nStoreDefinitions\n├── Stores: Record<StoreID, StoreDefinition>\n│ └── Layout.Sections: Record<SectionID, StoreSectionDefinition>\n│ └── Slots: Record<SlotID, StoreSlotDefinition>\n│ └── Rotation → decides which OfferID stands here\n└── StoreOffers: Record<OfferID, StoreOfferDefinition> ← flat catalogue\n```\n\n⚠⚠ **The slot is the address.** A product is bought through the slot it stands\nin; a product in no slot cannot be bought at all. The catalogue is flat so the\nsame product can stand in several slots and several storefronts, but membership\nis expressed by the **layout**, never by a field on the product.\n\nFour blocks repeat across levels:\n\n| Block | On | Contents |\n| ---------------------- | --------------------------- | ------------------------------------------------------------------------------- |\n| `Identity` | store, section, slot, offer | `DisplayName`, `Description`, `SortOrder`, `Tags`, `AssetPaths`, `CustomParams` |\n| `Availability` | store, section, slot | `Schedule` (on a slot: the **rotation window**), `Gate` |\n| `Rotation` | slot | `Mode`, `OfferID`, `Pool`, `Salt`, `PerInstanceCap` |\n| `Availability` (offer) | offer | `Schedule` (its own sale window), `Gate`, `Limits` |\n\n⚠ Every block field is optional, and that is a **requirement**, not laxity. The\nbackend merges presets by the rule _not set = inherit from the preset; set —\nincluding `0`, `false` and `[]` — = final_. A default value would make \"not\nset\" indistinguishable from \"set to empty\".\n\n---\n\n## Storefront: what `getStorefront()` returns\n\n```ts\ninterface GetStorefrontResponse {\n ServerTimeUtc: string; // count down from THIS, not the device clock\n Stores?: StorefrontView[]; // gated storefronts are absent entirely\n AdCreditBalance: number; // rewarded-video credits the player holds\n}\n```\n\n```ts\ninterface StorefrontView {\n StoreID: string;\n Identity?: StoreIdentity;\n SortOrder: number;\n ScheduleInstanceKey?: string; // current window of the storefront\n ExpiresAtUtc?: string; // null = no expiry\n Sections?: StorefrontSectionView[];\n}\n\ninterface StorefrontSectionView {\n SectionID: string;\n Identity?: StoreIdentity;\n SortOrder: number;\n Slots?: StorefrontSlotView[];\n}\n\ninterface StorefrontSlotView {\n SlotID: string;\n Identity?: StoreIdentity;\n SortOrder: number;\n RotationMode?: string; // \"Fixed\" | \"Title\" | \"Player\"\n RotationInstanceKey?: string; // current rotation window\n RefreshesAtUtc?: string; // the card's countdown; null = never refreshes\n RemainingThisRotation?: number; // null = unlimited (NOT zero)\n Offer?: StorefrontOfferView; // null = the slot is EMPTY — still draw it\n}\n\ninterface StorefrontOfferView {\n OfferID: string;\n Identity?: StoreIdentity;\n PriceOptions?: StorefrontPriceOptionView[]; // already filtered to this platform\n Rewards?: ResourceGrant;\n State?: StoreOfferStateView;\n}\n\ninterface StorefrontPriceOptionView {\n OptionID: string;\n Name?: string;\n Cost?: ResourceConsume;\n CompareAtCost?: ResourceConsume; // display only — never charged\n DiscountPercent?: number; // null = cannot be computed, NOT zero\n IsFree: boolean;\n IsAdPaid: boolean; // rewarded-video credit\n IsStorePaid: boolean; // real money; needs a receipt\n AssetPaths?: Record<string, string>;\n}\n\ninterface StoreOfferStateView {\n PurchasedTotal: number;\n PurchasedToday: number;\n PurchasedThisRotation: number;\n RemainingTotal?: number; // null = no lifetime cap\n RemainingToday?: number; // null = no daily cap\n IsFirstPurchaseAvailable: boolean; // the FIRST PURCHASE badge\n SoldOut: boolean;\n AvailableAtUtc?: string; // cooldown: \"Ready in 7h 59m\"\n}\n```\n\n**Three things the client must not recompute**, because they are already here:\n\n1. `IsFirstPurchaseAvailable` — `TotalCap == 1` and never bought. It goes out\n by itself after the purchase, because it is computed from the counter.\n2. `SoldOut` — any of the caps (lifetime, daily, this rotation window) spent.\n3. `DiscountPercent` — computed only when both the price and the struck-through\n price reduce to a single entry of the same currency. A mixed bundle has no\n single percentage, and the server does not invent one.\n\n⚠ **An empty slot arrives as a slot with `Offer: null`** — window closed, pool\nfiltered out by gates, or nothing pinned. Draw an empty frame; filtering these\nout makes the shelf jump around exactly when a window turns over.\n\n---\n\n## Rotation\n\n```ts\ninterface StoreSlotRotation {\n Mode?: \"Fixed\" | \"Title\" | \"Player\";\n OfferID?: string; // Fixed only\n Pool?: StoreSlotPoolEntry[];\n Salt?: string;\n PerInstanceCap?: number; // purchases inside ONE window; 0/unset = unlimited\n}\n\ninterface StoreSlotPoolEntry {\n OfferID?: string;\n Weight?: number; // 0 or less = never rolls\n Gate?: SegmentGate; // dropped BEFORE the roll\n}\n```\n\n| Mode | Meaning |\n| -------- | --------------------------------------------------------------------------- |\n| `Fixed` | A pinned product. This is an ordinary shelf: six SKUs are six pinned slots. |\n| `Title` | One roll for the whole title — everyone sees the same thing. |\n| `Player` | A personal roll — every player gets their own shelf. |\n\n**How it works, and why it matters to the client:**\n\n- The roll is a pure function of (mode, player, slot address, rotation window,\n salt, pool). It is **recomputed on every read and stored nowhere** — which is\n what keeps `getStorefront()` a pure read, with no per-player lock on the most\n frequently opened screen in a game.\n- The consequence is named openly: **editing a pool reshuffles the shelf for\n everyone who has it open.** Completed purchases are never undone — counters\n are addressed by product and slot, not by the roll — but a player who taps a\n card that is no longer in their roll gets `OFFER_NOT_IN_ROTATION`. Treat that\n as \"reload the storefront\", not as an error to show.\n- A pool entry closed by its `Gate` is dropped **before** the roll, so its\n weight is redistributed among the rest rather than producing an empty slot.\n- `UniqueOffersInSection` on a section stops one product from rolling into two\n slots of the same section.\n- The **rotation window is the slot's `Availability.Schedule`**. A slot with no\n schedule never refreshes: it rolls once and stays. A section's schedule only\n decides whether the section is shown at all.\n\n---\n\n## Config: StoreDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<StoreDefinitions>(\"Store\")`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreOfferDefinition {\n OfferID?: string;\n Identity?: StoreIdentity;\n Pricing?: {\n Options?: PriceOptions; // key = PriceOption.OptionID\n CompareAt?: Record<string, ResourceConsume>; // key = OptionID; display only\n };\n Reward?: { Grant?: ResourceGrant };\n Availability?: {\n Schedule?: ScheduleSpec;\n Gate?: SegmentGate;\n Limits?: LimitSpec;\n };\n}\n```\n\n⚠ **`Pricing.Options` empty or unset means FREE**, which is a legal product.\nThe rule the server enforces: a free product must be limited on some axis —\n`TotalCap`, `DailyCap`, `CooldownSeconds`, or the slot's `PerInstanceCap`.\nOtherwise the purchase is refused with _\"A free offer must be limited …\"_,\nbecause an unlimited free product is a button that grants itself forever.\n\nUse this config only for catalogue-wide tooling. A shop screen draws the\nstorefront.\n\n---\n\n## Player state\n\nReturned by `getUserState()`, cached at `client.data.user.state?.Store`.\n\n```ts\ninterface UserStoreState {\n Purchases?: Record<string /* OfferID */, StorePurchaseState>;\n Rotations?: Record<\n string /* \"{storeID}:{sectionID}:{slotID}\" */,\n StoreRotationPurchaseState\n >;\n}\n\ninterface StorePurchaseState {\n OfferID: string;\n TotalPurchases: number; // lifetime\n DailyPurchases: number; // since the last DailyResetUtc\n DailyResetUtc: string; // ISO — when DailyPurchases resets to 0\n LastPurchasedAt: string; // ISO — feeds the cooldown\n}\n\ninterface StoreRotationPurchaseState {\n SlotKey: string;\n InstanceKey: string; // the rotation window this count belongs to\n OfferID?: string;\n Purchases: number;\n LastPurchasedAt: string;\n}\n```\n\n⚠ `Rotations` is keyed by the **slot**, with the window as a _value_. Keying it\nby the window would grow one entry per slot per day forever, in a document read\non every request; keyed by slot it is bounded by the publisher's config and\ndoes not grow with time. Changing `InstanceKey` is what resets the counter.\n\nThis is a **counter store, not a purchase log**. A server-side audit collection\nexists but is not exposed through any Store endpoint.\n\nA missing key means zero — no purchase yet.\n\n---\n\n## Purchase-limit rule matrix\n\n`Availability.Limits` is the shared `LimitSpec`; Store reads three axes.\n\n| Axis | Meaning | `0` means |\n| ----------------- | ---------------------------------- | ----------- |\n| `TotalCap` | lifetime purchases of this product | unlimited |\n| `DailyCap` | purchases per **UTC calendar day** | unlimited |\n| `CooldownSeconds` | pause between purchases | no cooldown |\n\nPlus one axis that lives on the **slot**, not the product:\n\n| Axis | Meaning |\n| ------------------------- | --------------------------------------------------- |\n| `Rotation.PerInstanceCap` | purchases from this slot inside one rotation window |\n\n⚠ `DailyCap` and `PerInstanceCap` are **different axes and are not\ninterchangeable**. The daily cap counts UTC calendar days; a rotation window is\nwhatever the slot's schedule says — \"refreshes every 8 hours\" is a legitimate\nshelf, and for it the two do not line up at all.\n\nEvery axis is enforced twice: a fail-fast check for a readable error, and a\ncondition inside the write itself so two parallel purchases cannot both pass.\n\n---\n\n## Purchase flow, addressing, and idempotency\n\nOrder of checks on a purchase:\n\n1. product exists in the catalogue\n2. product's own sale window\n3. product shape (a reward is required; a price is not)\n4. audience gate\n5. **slot address → the rotation roll is re-run and compared** →\n `OFFER_NOT_IN_ROTATION`\n6. purchase limits, then the rotation-window cap\n7. payment option chosen (filtered by platform); a free option must be limited\n8. real-money receipt, if the option is store-paid\n9. scaling by `count`, then premium discounts/bonuses\n10. one atomic resource operation with the counter writes attached\n\n⚠ Step 5 is a **security property, not bookkeeping**. Without it a player who\nread the pool out of the config could buy anything from it regardless of what\nrolled — meaning rotation would restrict nothing.\n\nThe slot address (`StoreID` + `SectionID` + `SlotID`) is optional as a whole:\nsent, it is honoured literally; omitted, the server finds the first slot in its\ndeterministic walk order that currently holds the product. The roll is\nre-checked in both cases.\n\nIdempotency: the SDK mints `store_buy_{offerID}_{userID}_{uuid}` per call, so\ntwo calls are two real operations. Disable the buy control while one is in\nflight.\n\n---\n\n## Batch purchase semantics\n\n`purchaseBatch(refs)` — `refs` deduped by `OfferID`, each `Count` clamped to\n1–100, the list clamped to 50.\n\n- Items rejected on their own merits (unknown id, window, gate, limit, not in\n rotation) are filtered out **before** the merged charge is built and report\n their own reason; they never affect the others.\n- The survivors are charged as **one merged, all-or-nothing transaction**. If\n the combined cost cannot be paid, every survivor returns `Success: false`.\n- ⚠ A **store-paid** option cannot go in a batch: one receipt pays for one\n purchase. **Ad credits can** — a credit is an ordinary resource, and two of\n them add up like two coins.\n- ⚠ Two items may not resolve to the **same slot**; such an item is refused\n rather than failing the batch.\n- The cache applies the first successful item's `Resources` (the charge is\n merged server-side), but every item's own `OfferID`/`Count`/`ServerTimeUtc`\n is correct and drives its own counter patch.\n\n---\n\n## Special values\n\n| Value | Meaning |\n| ---------------------------------------------------------------------- | ----------------------------------------------------- |\n| `Pricing.Options` empty/unset | **free** — a legal product, but it must be limited |\n| `Cost` present, bundle empty | same as above: free |\n| `RemainingTotal` / `RemainingToday` / `RemainingThisRotation` = `null` | no limit on that axis |\n| the same fields = `0` | spent — sold out |\n| `DiscountPercent` = `null` | cannot be computed (mixed bundle) — do not print \"0%\" |\n| `Offer` = `null` on a slot | the slot is empty; draw an empty frame |\n| `RefreshesAtUtc` = `null` | the slot never refreshes |\n| `Weight` ≤ 0 on a pool entry | that entry never rolls |\n| `PerInstanceCap` = 0/unset | no per-window cap |\n| `TotalCap` / `DailyCap` / `CooldownSeconds` = 0 | unlimited on that axis |\n| `AvailableAtUtc` = `null` | no cooldown, or it has already elapsed |\n| `SortOrder` unset | sorted last |\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|