@idosgames/mcp 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +5 -1
- package/registry/index.json +29 -17
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/acquisition-attribution.json +1 -1
- package/registry/skills/blockchain-system.json +2 -2
- package/registry/skills/chat-system.json +6 -0
- package/registry/skills/community-marketing-system.json +6 -0
- package/registry/skills/currency-system.json +2 -2
- package/registry/skills/deal-offer-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": "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
|
}
|