@idosgames/mcp 0.1.40 → 0.1.41
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 +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +105 -105
- package/registry/modules/ai.json +3 -3
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +3 -3
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +3 -3
- package/registry/modules/coop-events.json +3 -3
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +3 -3
- package/registry/modules/events.json +3 -3
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +3 -3
- package/registry/modules/idle-rpg.json +3 -3
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +6 -6
- package/registry/modules/leaderboard.json +3 -3
- package/registry/modules/lootboxes.json +3 -3
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +3 -3
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +3 -3
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/video-studio.json +3 -3
- package/registry/modules/voxelcraft.json +3 -3
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/hero-run.json +1 -1
- package/registry/skills/referral-system.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "referral-system",
|
|
3
3
|
"description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (their own SHORT invite code, a ready-made invite link, who they're subscribed to, follower count, claimed invite rewards), activate someone else's code, and claim a staged invite reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants invite-friend / referral-code / refer-a-friend UIs, follower-milestone reward screens, or otherwise touches client.referral, ReferralService, ReferralDefinitions, UserReferralState, or referral codes — even if they don't name the module explicitly. Also use it for \"share my invite link\", \"enter a friend's code\" and invite-code screens.",
|
|
4
|
-
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (their own SHORT invite code, a ready-made invite link, who they're\n subscribed to, follower count, claimed invite rewards), activate someone\n else's code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly. Also use\n it for \"share my invite link\", \"enter a friend's code\" and invite-code\n screens.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player gets\na **short human code** (`WDJBMJHT`) and a ready-made **invite link**, a new\nplayer **activates** someone else's code once, the referrer's `FollowersCount`\ngoes up, and the referrer can later **claim** staged rewards as that count\ncrosses configured thresholds.\n\n> ⚠ **A code is NOT a `UserID`.** Older docs and older UI said it was, and it\n> used to be true. It is not any more: the code is 8 characters from a\n> confusable-free alphabet, minted per player per title, and a `UserID` is not\n> accepted in the code field at all. Never render a `UserID` as \"your code\".\n\nMost bindings never touch this module's UI: the SDK captures `?ref=`, an\ninvite link, a Telegram `start_param` and ad tags at launch and ships them with\nthe login, so the server binds the player before any screen is shown. Manual\nentry is the fallback for people who arrived without a link. That capture side\n— and the attribution it feeds — is the `acquisition-attribution` skill; this\none is only about the referral loop itself. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — this player's own `Code`, who\n they activated (`SubscribedToUserID`), whether their activation reward was\n granted, whether one is still `PendingActivationReward`, their own\n `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`, which also returns\n `InviteUrl` alongside it (on the response, not inside `Referral`).\n\n⚠ **`getUserState()` is not a pure read, and two things depend on that.** The\nserver mints the player's `Code` LAZILY on that call — most players never\ninvite anyone, so nobody gets a code until they open the screen — and it\nsettles `PendingActivationReward`, paying the activation reward to a player who\nwas bound at login rather than by typing a code. So: call it when you open the\ninvite screen, and do not serve that screen from a stale cache.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to 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 referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ 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` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a **deterministic** `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nBecause the key is deterministic, a **retry of the same operation is a replay,\nnot a second grant**: `ResourceService` recognises the reason, returns the\nstored result and grants nothing again. Still disable the button while a call\nis in flight — that's about the player seeing what happened, not about\ndouble-paying.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to `data.ReferrerUserID` — the referrer's **id**, which\n the response carries separately from the code that was typed — clears\n `PendingActivationReward`, and applies `data.Resources` (the\n `ActivationReward`, only present when `IsFirstActivation` is true) to cached\n balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **The code must be well-formed before it is even looked up.** Eight\n characters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, no `0/O`, no `1/I/L`),\n separators ignored. Anything else — including a `UserID` — fails with\n `\"Referral code is invalid\"` without a database round-trip.\n- **An unknown but well-formed code** fails the same way.\n- **Self-referral is rejected** with `\"Cannot activate your own referral code\"`\n — the server resolves the code to a player first and compares ids, so a\n player pasting their own link gets this rather than a generic error.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount` is decremented, the new one incremented, and\n `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n// ⚠ There is no FollowerIDs — only the count. Activating a code also makes the\n// two players friends, so the identities are `client.social.getFriendsList()`.\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Show the player's own code and invite link\n\n```ts\nconst res = await client.referral.getUserState();\nif (!res.ok) return showLoadFailed(res.error); // NOT the same as \"no code yet\"\n\nconst code = res.data.Referral?.Code; // e.g. \"WDJBMJHT\"\nconst link = res.data.InviteUrl; // e.g. \"https://idosgames.com/go/MYTITLE/?ref=WDJBMJHT\"\n// Web-only title (no Google Play / App Store / Telegram link): \".../app/MYTITLE/?ref=WDJBMJHT\"\n\n// Group it for reading aloud; the server strips separators on the way back in.\nconst pretty =\n code?.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n```\n\nTake the link **as given**. Do not assemble one from a template: the server\nbuilds it precisely so a client cannot turn it into an open redirect, and so\nevery client words it identically. No `Code` means the title has no referral\nconfig at all — hide the sharing UI rather than showing an empty box.\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(pastedByPlayer);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID (the referrer's id) + the granted ActivationReward.\n```\n\nPass through **whatever the player pasted**: the SDK accepts a bare code, a\ncode with separators, and the whole invite link, in any case. People forward\nthe link they were sent far more often than they retype eight characters, and\nhandling that in each title's UI separately is how the two drift apart.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\n`SpendRewards` describes the _rules_ (rate, source/target currency, per\nfeature) and the backend applies them automatically: after a follower spends\nin Store, Marketplace, Lootbox, Craft, Item, Character, TimedBoost, Premium,\nCurrency or Reward, the referrer is credited.\n\nThere is still nothing to call from the referrer's side, and no `referral:*`\nevent fires for them: the payout lands in **another player's** document, in a\nrequest that player made. Read `defs?.SpendRewards` to show \"earn N% back when\nyour friends spend\", and show the balance itself from the referrer's own\nstate on their next request.\n\n## Gotchas\n\n- **`SpendRewards` is wired and pays.** (This entry used to claim the\n opposite; it was wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called\n from 12 modules after a successful deduction. What is still true: the\n referrer gets no event and no cache update from it, because the payout\n happens inside someone else's request. Their balance shows up on their next\n own request.\n- **The rate field is `Rate`, a FRACTION.** `0.05` means 5%. It is not\n `Percent`, and it is not `5`. A rule with no `Rate` is skipped silently — it\n neither pays nor errors.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **The code is a short human code (`WDJB-MJHT`), not a `UserID`.** The player\n reads it off a screen or gets it inside an invite link;\n `client.referral.activateReferralCode` accepts either the bare code or the\n whole link pasted. An internal identifier is not accepted at all.\n- **A code belongs to a TITLE, not to the platform.** It is stored under\n `{titleID}:{code}` and is unique only inside that title, which is why the\n invite link always carries the title (`/go/{titleID}?ref=...`, or\n `/app/{titleID}?ref=...` for a web-only title) and why a\n link shaped like `/i/{code}` cannot exist.\n- **Most players never type it.** `AcquisitionCapture` (in `@idosgames/core`,\n wired automatically) reads `?ref=`, `idos_click`, Telegram `start_param` and\n ad tags at launch, keeps them across the login screen, and ships them with\n whichever sign-in the player uses. Manual entry is the fallback for people\n who arrived without a link. You do not call it and must not duplicate it.\n- **A player bound at login has an unpaid reward until the screen is opened.**\n That is what `PendingActivationReward` means: the login path has no per-user\n lock, so the server flags the debt and settles it on the next\n `getUserState()`. If your game never opens an invite screen, that reward is\n never paid — put the call somewhere the player reaches.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard; the rejection (`\"Cannot activate your own referral code\"`) only comes\n back after the round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **A double-clicked \"Activate\"/\"Claim\" does NOT pay twice.** The idempotency\n key is deterministic, so the repeat is a replay and grants nothing again.\n (This entry used to claim the opposite.) Disable the control while a call is\n in flight anyway — so the player can tell what happened.\n- **Input is normalised for you** — `activateReferralCode` pulls the code out\n of a pasted link, strips separators, trims and uppercases before sending. Do\n not pre-clean it yourself; a second implementation of the same rule is how\n \"this code is invalid\" starts happening to valid codes.\n- **`SubscribedToUserID` holds an ID, `Code` holds a code.** They are different\n fields with different shapes, and the activation response carries both\n (`ReferrerUserID` and `ReferralCode`). Rendering one where the other belongs\n is the single easiest mistake here.\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\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
4
|
+
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (their own SHORT invite code, a ready-made invite link, who they're\n subscribed to, follower count, claimed invite rewards), activate someone\n else's code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly. Also use\n it for \"share my invite link\", \"enter a friend's code\" and invite-code\n screens.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player gets\na **short human code** (`WDJBMJHT`) and a ready-made **invite link**, a new\nplayer **activates** someone else's code once, the referrer's `FollowersCount`\ngoes up, and the referrer can later **claim** staged rewards as that count\ncrosses configured thresholds.\n\n> ⚠ **A code is NOT a `UserID`.** Older docs and older UI said it was, and it\n> used to be true. It is not any more: the code is 8 characters from a\n> confusable-free alphabet, minted per player per title, and a `UserID` is not\n> accepted in the code field at all. Never render a `UserID` as \"your code\".\n\nMost bindings never touch this module's UI: the SDK captures `?ref=`, an\ninvite link, a Telegram `start_param` and ad tags at launch and ships them with\nthe login, so the server binds the player before any screen is shown. Manual\nentry is the fallback for people who arrived without a link. That capture side\n— and the attribution it feeds — is the `acquisition-attribution` skill; this\none is only about the referral loop itself. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — this player's own `Code`, who\n they activated (`SubscribedToUserID`), whether their activation reward was\n granted, whether one is still `PendingActivationReward`, their own\n `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`, which also returns\n `InviteUrl` alongside it (on the response, not inside `Referral`).\n\n⚠ **`getUserState()` is not a pure read, and two things depend on that.** The\nserver mints the player's `Code` LAZILY on that call — most players never\ninvite anyone, so nobody gets a code until they open the screen — and it\nsettles `PendingActivationReward`, paying the activation reward to a player who\nwas bound at login rather than by typing a code. So: call it when you open the\ninvite screen, and do not serve that screen from a stale cache.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to 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 referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ 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` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n| `checkQualification()` | For an invited player waiting for the title's invite qualification: check the conditions now. Met = the invite counts right away. The client already calls it once after sign-in for a waiting player. | `CheckQualificationResponse` |\n\n**Invite qualification** (`ReferralDefinitions.Qualification`, set by the publisher): when present, an\ninvite counts only after the invited player meets ANY group of conditions (all conditions inside a\ngroup) — not a guest, days / sessions / minutes in the app, an audience gate, a quest, a linked\nwallet, tokens deposited, tokens held. Until then everything waits: the inviter's `FollowersCount`\n(and so the milestones), invite leaderboard points, the spend kickback and the activation reward.\nThe inviter sees waiting friends in `PendingFollowersCount`; the invited player has\n`QualificationPending` and, in `getUserState()`, `Qualification` with what is left. Sign-in itself\nchecks nothing: the client makes one `checkQualification()` call after sign-in, only for a waiting\nplayer — do not add your own on start-up; call it from a \"Check\" button. \"Tokens held\" on the wallet\nis the balance the title's sign-in token check measured (the server never reads the chain for it), so\nit works only on a title where that check is on for the same token.\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a **deterministic** `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nBecause the key is deterministic, a **retry of the same operation is a replay,\nnot a second grant**: `ResourceService` recognises the reason, returns the\nstored result and grants nothing again. Still disable the button while a call\nis in flight — that's about the player seeing what happened, not about\ndouble-paying.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to `data.ReferrerUserID` — the referrer's **id**, which\n the response carries separately from the code that was typed — clears\n `PendingActivationReward`, and applies `data.Resources` (the\n `ActivationReward`, only present when `IsFirstActivation` is true) to cached\n balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **The code must be well-formed before it is even looked up.** Eight\n characters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, no `0/O`, no `1/I/L`),\n separators ignored. Anything else — including a `UserID` — fails with\n `\"Referral code is invalid\"` without a database round-trip.\n- **An unknown but well-formed code** fails the same way.\n- **Self-referral is rejected** with `\"Cannot activate your own referral code\"`\n — the server resolves the code to a player first and compares ids, so a\n player pasting their own link gets this rather than a generic error.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount` is decremented, the new one incremented, and\n `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n// ⚠ There is no FollowerIDs — only the count. Activating a code also makes the\n// two players friends, so the identities are `client.social.getFriendsList()`.\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Show the player's own code and invite link\n\n```ts\nconst res = await client.referral.getUserState();\nif (!res.ok) return showLoadFailed(res.error); // NOT the same as \"no code yet\"\n\nconst code = res.data.Referral?.Code; // e.g. \"WDJBMJHT\"\nconst link = res.data.InviteUrl; // e.g. \"https://idosgames.com/go/MYTITLE/?ref=WDJBMJHT\"\n// Web-only title (no Google Play / App Store / Telegram link): \".../app/MYTITLE/?ref=WDJBMJHT\"\n\n// Group it for reading aloud; the server strips separators on the way back in.\nconst pretty =\n code?.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n```\n\nTake the link **as given**. Do not assemble one from a template: the server\nbuilds it precisely so a client cannot turn it into an open redirect, and so\nevery client words it identically. No `Code` means the title has no referral\nconfig at all — hide the sharing UI rather than showing an empty box.\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(pastedByPlayer);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID (the referrer's id) + the granted ActivationReward.\n```\n\nPass through **whatever the player pasted**: the SDK accepts a bare code, a\ncode with separators, and the whole invite link, in any case. People forward\nthe link they were sent far more often than they retype eight characters, and\nhandling that in each title's UI separately is how the two drift apart.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\n`SpendRewards` describes the _rules_ (rate, source/target currency, per\nfeature) and the backend applies them automatically: after a follower spends\nin Store, Marketplace, Lootbox, Craft, Item, Character, TimedBoost, Premium,\nCurrency or Reward, the referrer is credited.\n\nThere is still nothing to call from the referrer's side, and no `referral:*`\nevent fires for them: the payout lands in **another player's** document, in a\nrequest that player made. Read `defs?.SpendRewards` to show \"earn N% back when\nyour friends spend\", and show the balance itself from the referrer's own\nstate on their next request.\n\n## Gotchas\n\n- **`SpendRewards` is wired and pays.** (This entry used to claim the\n opposite; it was wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called\n from 12 modules after a successful deduction. What is still true: the\n referrer gets no event and no cache update from it, because the payout\n happens inside someone else's request. Their balance shows up on their next\n own request.\n- **The rate field is `Rate`, a FRACTION.** `0.05` means 5%. It is not\n `Percent`, and it is not `5`. A rule with no `Rate` is skipped silently — it\n neither pays nor errors.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **The code is a short human code (`WDJB-MJHT`), not a `UserID`.** The player\n reads it off a screen or gets it inside an invite link;\n `client.referral.activateReferralCode` accepts either the bare code or the\n whole link pasted. An internal identifier is not accepted at all.\n- **A code belongs to a TITLE, not to the platform.** It is stored under\n `{titleID}:{code}` and is unique only inside that title, which is why the\n invite link always carries the title (`/go/{titleID}?ref=...`, or\n `/app/{titleID}?ref=...` for a web-only title) and why a\n link shaped like `/i/{code}` cannot exist.\n- **Most players never type it.** `AcquisitionCapture` (in `@idosgames/core`,\n wired automatically) reads `?ref=`, `idos_click`, Telegram `start_param` and\n ad tags at launch, keeps them across the login screen, and ships them with\n whichever sign-in the player uses. Manual entry is the fallback for people\n who arrived without a link. You do not call it and must not duplicate it.\n- **A player bound at login has an unpaid reward until the screen is opened.**\n That is what `PendingActivationReward` means: the login path has no per-user\n lock, so the server flags the debt and settles it on the next\n `getUserState()`. If your game never opens an invite screen, that reward is\n never paid — put the call somewhere the player reaches.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard; the rejection (`\"Cannot activate your own referral code\"`) only comes\n back after the round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **A double-clicked \"Activate\"/\"Claim\" does NOT pay twice.** The idempotency\n key is deterministic, so the repeat is a replay and grants nothing again.\n (This entry used to claim the opposite.) Disable the control while a call is\n in flight anyway — so the player can tell what happened.\n- **Input is normalised for you** — `activateReferralCode` pulls the code out\n of a pasted link, strips separators, trims and uppercases before sending. Do\n not pre-clean it yourself; a second implementation of the same rule is how\n \"this code is invalid\" starts happening to valid codes.\n- **`SubscribedToUserID` holds an ID, `Code` holds a code.** They are different\n fields with different shapes, and the activation response carries both\n (`ReferrerUserID` and `ReferralCode`). Rendering one where the other belongs\n is the single easiest mistake here.\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\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "store-system",
|
|
3
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": "---\r\nname: store-system\r\ndescription: >-\r\n Build a store / shop screen in a game on the iDosGames TypeScript SDK\r\n (@idosgames/core) via client.store (StoreService): load the RESOLVED\r\n storefront for the current player (rotating daily shops, sections, slots,\r\n badges, refresh timers, remaining limits), load the offer catalogue, and\r\n purchase one or many offers. Prices can be virtual currency, crypto, a real\r\n money store product, a rewarded-video ad credit, or free. Use this whenever\r\n the user is working in the iDosGames TS SDK or its game templates\r\n (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem\r\n packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or\r\n otherwise touches client.store, StoreService, GetStorefrontResponse,\r\n StoreDefinitions or offer purchasing — even if they don't name the module.\r\n---\r\n\r\n# Store system (iDosGames TS SDK)\r\n\r\nA title sells **offers** (products) placed on **shelves**. The shop is a tree:\r\n\r\n```\r\nStore (storefront)\r\n└── Layout.Sections\r\n └── Slots ← a slot is a PLACE on the shelf\r\n └── the slot decides which offer stands in it\r\n```\r\n\r\n⚠⚠ **A slot is the only address a product can be bought through.** An offer\r\nlives in a flat catalogue (`StoreOffers`) and becomes buyable only by standing\r\nin a slot; a product placed in no slot **cannot be bought at all**. There is no\r\n\"list of stores\" on the product — that field was removed, because it was never\r\na right to buy: the purchase never checked it.\r\n\r\nEverything is **server-authoritative**. The client asks to purchase, the\r\nbackend validates price, windows, gates, limits **and the rotation roll**, then\r\nthe SDK mirrors the confirmed result into a local cache your UI reads.\r\n\r\n## ⚠ Draw `getStorefront()`, not `getDefinitions()`\r\n\r\nThis is the single most important rule of the module.\r\n\r\n`getDefinitions()` returns the **config** — identical for every player.\r\n`getStorefront()` returns the shop **resolved for this player**: rotation\r\nalready rolled, gates applied, badges and refresh timers computed, remaining\r\nlimits filled in. None of that can be derived on the client:\r\n\r\n- **Rotation** is recomputed by the server on every read and stored nowhere. A\r\n client rolling it itself would show two devices two different shops.\r\n- **Counters** (`PurchasedTotal`, remaining caps, cooldown) belong to the\r\n player and are not in the config.\r\n- **Gates** filter storefronts, sections, slots and pool entries per player.\r\n\r\nUse `getDefinitions()` only for catalogue-wide tooling (an admin view, a\r\nsearch). A shop screen never needs it.\r\n\r\n## Prices: six kinds, one shape\r\n\r\nPrice is always a `PriceOptions` dictionary (`Pricing.Options`), the\r\nplatform-wide standard. An option's cost can be:\r\n\r\n| Kind | What it is | On the storefront view |\r\n| --------------------------------------- | ------------------------------------------- | ---------------------- |\r\n| Virtual currency / items / event tokens | ordinary `ResourceConsume` | — |\r\n| Crypto | a crypto currency entry | — |\r\n| Real money | a store product entry; needs a receipt | `IsStorePaid: true` |\r\n| Rewarded video | an ad-credit entry, spent like any resource | `IsAdPaid: true` |\r\n| **Credits** (platform currency) | a `PlatformCredits` entry, `Amount` = price | `IsCreditsPaid`, `CreditsPrice` |\r\n| **Free** | **no cost at all** | `IsFree: true` |\r\n\r\n⚠ **An unset cost means FREE, and that is a legal product** — not an unfilled\r\nprice. A free offer must be limited on some axis (`TotalCap`, `DailyCap`,\r\n`CooldownSeconds`, or the slot's `PerInstanceCap`); without a limit the server\r\nrefuses the purchase, because it would be a button that grants itself forever.\r\n\r\n⚠ Ad-paid offers spend a **credit the player already earned** by watching a\r\nvideo (the Advertising module grants it server-side). Read the balance from\r\n`storefront.AdCreditBalance` to decide whether a card shows \"Watch\" or \"Buy\".\r\n\r\n## Setup\r\n\r\n```ts\r\nimport { createIDosGamesClient } from \"@idosgames/core\";\r\n\r\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\r\nawait client.auth.loginWithDeviceID(); // or any auth.* method\r\n\r\nconst store = client.store; // the StoreService\r\n```\r\n\r\nEvery store method requires an authenticated session. Without one they return\r\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\r\n\r\n## Methods\r\n\r\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\r\nor `{ ok: false, reason, error }`. Always branch on `result.ok`.\r\n\r\n| Method | Purpose | `data` on success |\r\n| ------------------------------------- | ---------------------------------------- | ----------------------- |\r\n| `getStorefront()` | **The shop as this player sees it now.** | `GetStorefrontResponse` |\r\n| `getDefinitions()` | The raw catalogue (config). | `StoreDefinitions` |\r\n| `getUserState()` | Purchase counters (state). | `UserStoreState` |\r\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` |\r\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` |\r\n\r\n`options` = `{ selectedOptionID?, payment?, slot? }`.\r\n\r\n`purchase` clamps `count` server-side to **1–100**. `purchaseBatch` takes\r\n`StorePurchaseRef[]`, deduped by `OfferID`, clamped to **50 refs per call**.\r\n\r\n## Drawing a shop screen\r\n\r\n```ts\r\nconst res = await client.store.getStorefront();\r\nif (!res.ok) return showError(res.error);\r\nconst front = res.data;\r\n\r\nfor (const store of front.Stores ?? []) {\r\n for (const section of store.Sections ?? []) {\r\n for (const slot of section.Slots ?? []) {\r\n // ⚠ An EMPTY slot arrives with Offer === null and must still be drawn as\r\n // an empty frame. Skipping it makes the shelf jump around whenever a\r\n // window closes or a gate filters the pool — which is exactly why the\r\n // server sends the slot rather than omitting it.\r\n if (!slot.Offer) {\r\n drawEmptyCard(slot.RefreshesAtUtc); // \"opens in ...\"\r\n continue;\r\n }\r\n\r\n const offer = slot.Offer;\r\n const state = offer.State;\r\n\r\n drawCard({\r\n title: offer.Identity?.DisplayName, // localization key OR a literal\r\n ribbons: offer.Identity?.Tags, // static badges: best_value, x2 ...\r\n // Server-computed badges — do not recompute these:\r\n firstPurchase: state?.IsFirstPurchaseAvailable,\r\n soldOut: state?.SoldOut,\r\n readyAt: state?.AvailableAtUtc, // free-slot cooldown\r\n // Rotation timer: \"Refreshes in 15h 40m\"\r\n refreshesAt: slot.RefreshesAtUtc,\r\n remainingThisWindow: slot.RemainingThisRotation, // null = unlimited\r\n prices: offer.PriceOptions, // already filtered to this platform\r\n });\r\n }\r\n }\r\n}\r\n```\r\n\r\n⚠ **Count timers down from `front.ServerTimeUtc`, not from the device clock.**\r\nThat is what it is for; a player with a skewed clock otherwise sees a shop that\r\nrefreshes at the wrong moment or claims to have already refreshed.\r\n\r\n⚠ **`null` and `0` mean different things in every remaining-count field.**\r\n`null` = \"no limit on this axis\"; `0` = \"spent, sold out\". Treating `null` as\r\nzero grays out every unlimited product on the shelf.\r\n\r\n`client.store.nextRefreshAtUtc()` returns the nearest refresh across all\r\nvisible slots — use it to schedule one reload instead of a polling loop.\r\n\r\n## Purchasing\r\n\r\n```ts\r\nconst res = await client.store.purchase(offer.OfferID, 1, {\r\n selectedOptionID: option.OptionID,\r\n // Pass the slot the player tapped — the storefront gives you all three ids.\r\n slot: {\r\n storeID: store.StoreID,\r\n sectionID: section.SectionID,\r\n slotID: slot.SlotID,\r\n },\r\n});\r\nif (!res.ok) return showError(res.error);\r\n```\r\n\r\nAn option paid in a store needs the receipt as well:\r\n\r\n```ts\r\nawait client.store.purchase(\"gems_l\", 1, {\r\n selectedOptionID: option.OptionID,\r\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\r\n});\r\n```\r\n\r\n⚠ **The slot address is optional as a whole** (all three or none): without it\r\nthe server finds the slot itself. The roll is re-checked either way, so a\r\nproduct that rolled for the player nowhere cannot be bought with or without an\r\naddress. Pass it anyway when you have it — for a pooled product it removes the\r\nambiguity of which slot's per-window cap applies.\r\n\r\n### Credits — the platform currency (`IsCreditsPaid`)\r\n\r\n⚠ **Players see this currency as \"Aspis\"** (owner's decision 26.10.2026) — write \"Aspis\" in any player-facing text\r\n(price \"100 Aspis\", \"Not enough Aspis\"). \"Credits\" is only the API name (`PlatformCredits`, `purchaseWithCredits`,\r\n`CreditsError`, `IsCreditsPaid`); do not name a game's own currency \"Coins\" next to it either — players confuse them.\r\n\r\nCredits live on the player's **iDos Games account**, not in the game; the game's author gets 70% of the price, the\r\nplatform 30%. ⚠ The game **cannot charge Credits by itself**: the first call only creates a purchase request, the\r\nplayer confirms it on idosgames.com with their own account, and only then the purchase completes. On idosgames.com's\r\nown game page the SITE shows its confirmation window right over the game — `purchaseWithCredits` handles that by itself\r\nand `onConfirmation` is not called; on the game's own address or `/embed` it falls back to the link. Use the one call\r\nthat does the whole flow:\r\n\r\n```ts\r\nimport { CreditsError } from \"@idosgames/core\";\r\n\r\nconst res = await client.store.purchaseWithCredits(offer.OfferID, 1, {\r\n selectedOptionID: option.OptionID,\r\n slot: { storeID, sectionID, slotID },\r\n // Show a LINK/button to view.Url (target=\"_blank\") — not window.open after an await: browsers block that.\r\n onConfirmation: (view) => showConfirmLink(view.Url),\r\n signal: abort.signal, // stop waiting when the popup closes\r\n});\r\nif (!res.ok && res.error === CreditsError.NotEnough) {\r\n const b = await client.store.getCreditsBalance(); // b.data.TopUpUrl — where to top up\r\n}\r\n```\r\n\r\n- Price the option in Credits in LiveOps → Store (resource picker → **Credits**). Only the Store accepts Credits; one Credits\r\n entry per option, never together with a store product, never as a reward.\r\n- Refusals (`CreditsError`): `CREDITS_LOGIN_REQUIRED` (guest — sign in with iDos Games), `NOT_ENOUGH_CREDITS`,\r\n `CREDITS_CONFIRMATION_DECLINED` / `_EXPIRED`, `CREDITS_BATCH_FORBIDDEN` (no Credits in `purchaseBatch`).\r\n- `getCreditsBalance()` — Credits the player can spend in games (bought + earned; bonus Credits are for platform services).\r\n- On the DEV copy of the title a Credits purchase is a free test: no request, no Credits moved.\r\n\r\n### ⚠ `OFFER_NOT_IN_ROTATION` means \"reload the shop\", not \"show an error\"\r\n\r\nThe rotation roll is recomputed, never stored, so editing a slot's pool\r\nreshuffles the shelf for everyone who has it open. A player who taps a card\r\nthat is no longer in their roll gets exactly this error:\r\n\r\n```ts\r\nimport { OFFER_NOT_IN_ROTATION } from \"@idosgames/core\";\r\n\r\nif (!res.ok && res.error === OFFER_NOT_IN_ROTATION) {\r\n await client.store.getStorefront(); // silently redraw; do not toast\r\n return;\r\n}\r\n```\r\n\r\nAlready-completed purchases are never undone by a reshuffle — counters are\r\naddressed by product and slot, not by the roll.\r\n\r\n## Events\r\n\r\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\r\n\r\n- `store:storefrontLoaded` → `GetStorefrontResponse`\r\n- `store:definitionsLoaded` → `StoreDefinitions`\r\n- `store:userStateLoaded` → `UserStoreState`\r\n- `store:offerPurchased` → `StorePurchaseResponse`\r\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\r\n\r\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\r\ncache write.\r\n\r\n## Batch purchase\r\n\r\n```ts\r\nconst res = await client.store.purchaseBatch([\r\n { OfferID: \"pack1\", Count: 1 },\r\n { OfferID: \"starter_bundle\", Count: 1 },\r\n]);\r\nif (!res.ok) return showError(res.error);\r\nfor (const item of res.data) {\r\n if (item.Success) applyOk(item.Id);\r\n else showItemError(item.Id, item.Error);\r\n}\r\n```\r\n\r\nBatch results are **partial-aware**: the outer `res.ok` says the call ran, each\r\nelement's `Success`/`Error` says whether that item applied. Items rejected on\r\ntheir own merits (unknown id, outside window, gate failed, limit reached, not\r\nin rotation) are filtered out _before_ the merged charge is built. The\r\nsurvivors are then charged as **one merged, all-or-nothing transaction**.\r\n\r\n⚠ An option paid in a **store** cannot go in a batch — one receipt pays for one\r\npurchase. Ad credits can: a credit is an ordinary resource and two of them add\r\nup like two coins.\r\n\r\n## Gotchas\r\n\r\n- **Guard against double-submit.** Each `purchase()` mints a fresh idempotency\r\n key, so two calls are two real operations — a double-clicked \"Buy\" charges\r\n twice. Disable the control while a call is in flight.\r\n- **The struck-through price is display only.** `CompareAtCost` never reaches\r\n the charge. `DiscountPercent` is computed by the server and is `null` when it\r\n cannot be computed (a mixed bundle has no single percentage) — `null` is not\r\n zero, and printing \"0%\" there is a lie.\r\n- **Prices and rewards can be adjusted server-side.** Premium tiers apply\r\n discounts and bonuses automatically; read what was actually charged and\r\n granted off `res.data.Resources`, not off the displayed base values.\r\n- **`count` scales cost and rewards linearly, then premium is applied once** —\r\n it is not N independent purchases.\r\n- **Only one `Resources` apply per batch call**, but every item's own\r\n `OfferID`/`Count`/`ServerTimeUtc` is correct and drives its counter patch.\r\n- **`DailyPurchases` resets on UTC midnight.** Read `DailyResetUtc` from the\r\n cache after a call rather than computing it.\r\n- **Tags are for the client; player-dependent badges are not.** Anything whose\r\n truth depends on the player — first purchase, sold out, a timer — arrives in\r\n `State`, computed by the server. Do not encode those as tags.\r\n- **`DisplayName` is a localization key OR a literal.** Resolution is\r\n `t(x) = own table → fallback table → x itself`, so a title with literal\r\n names works with no migration. Run these through the localization module.\r\n- **Render from the cache, handle the error from the result.** Use `reason` to\r\n decide behaviour (retry on `\"connection\"`, re-auth on `\"unauthorized\"`, toast\r\n the `error` on `\"server\"`).\r\n\r\n## Full reference\r\n\r\n[references/data-model.md](references/data-model.md) — every config and\r\nstorefront field, the rotation modes, the purchase-limit/reset rule matrix, and\r\nbatch semantics. Read it when building config-driven UI or when an error points\r\nat a rule you need to understand.\r\n",
|
|
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: six 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| **Credits** (platform currency) | a `PlatformCredits` entry, `Amount` = price | `IsCreditsPaid`, `CreditsPrice` |\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### Credits — the platform currency (`IsCreditsPaid`)\n\n⚠ **Players see this currency as \"Aspis\"** (owner's decision 26.10.2026) — write \"Aspis\" in any player-facing text\n(price \"100 Aspis\", \"Not enough Aspis\"). \"Credits\" is only the API name (`PlatformCredits`, `purchaseWithCredits`,\n`CreditsError`, `IsCreditsPaid`); do not name a game's own currency \"Coins\" next to it either — players confuse them.\n\nCredits live on the player's **iDos Games account**, not in the game; the game's author gets 70% of the price, the\nplatform 30%. ⚠ The game **cannot charge Credits by itself**: the first call only creates a purchase request, the\nplayer confirms it on idosgames.com with their own account, and only then the purchase completes. On idosgames.com's\nown game page the SITE shows its confirmation window right over the game — `purchaseWithCredits` handles that by itself\nand `onConfirmation` is not called; on the game's own address or `/embed` it falls back to the link. Use the one call\nthat does the whole flow:\n\n```ts\nimport { CreditsError } from \"@idosgames/core\";\n\nconst res = await client.store.purchaseWithCredits(offer.OfferID, 1, {\n selectedOptionID: option.OptionID,\n slot: { storeID, sectionID, slotID },\n // Show a LINK/button to view.Url (target=\"_blank\") — not window.open after an await: browsers block that.\n onConfirmation: (view) => showConfirmLink(view.Url),\n signal: abort.signal, // stop waiting when the popup closes\n});\nif (!res.ok && res.error === CreditsError.NotEnough) {\n const b = await client.store.getCreditsBalance(); // b.data.TopUpUrl — where to top up\n}\n```\n\n- Price the option in Credits in LiveOps → Store (resource picker → **Credits**). Only the Store accepts Credits; one Credits\n entry per option, never together with a store product, never as a reward.\n- Refusals (`CreditsError`): `CREDITS_LOGIN_REQUIRED` (guest — sign in with iDos Games), `NOT_ENOUGH_CREDITS`,\n `CREDITS_CONFIRMATION_DECLINED` / `_EXPIRED`, `CREDITS_BATCH_FORBIDDEN` (no Credits in `purchaseBatch`).\n- `getCreditsBalance()` — Credits the player can spend in games (bought + earned; bonus Credits are for platform services).\n- On the DEV copy of the title a Credits purchase is a free test: no request, no Credits moved.\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",
|