@idosgames/mcp 0.1.8 → 0.1.9
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 +2 -2
- package/registry/index.json +20 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +5 -5
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/acquisition-attribution.json +6 -0
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/game-loop-system.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/lootbox-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +1 -1
- package/registry/skills/purchase-system.json +2 -2
- package/registry/skills/referral-system.json +3 -3
- package/registry/skills/reward-system.json +1 -1
- package/registry/skills/social-system.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Premium data model — reference\r\n\r\nFull shape of the config (Definitions) and player state, the tier-resolution\r\nand trial rules the backend enforces, and the purchase/receipt flow. All of\r\nthese are **strictly typed in the SDK** — `PremiumDefinitions` and its nested\r\nblocks (`PremiumDefinition`, `PriceOption`) are exported from\r\n`@idosgames/core`, so `getDefinitions()` and\r\n`getSection<PremiumDefinitions>(\"Premium\")` give you concrete types, not\r\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\r\nlater still round-trips. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\n## Contents\r\n\r\n- [Player state](#player-state) — what `getUserState()` returns\r\n- [Config: PremiumDefinitions](#config-premiumdefinitions) — what `getDefinitions()` returns\r\n- [PremiumDefinition](#premiumdefinition)\r\n- [PriceOption](#priceoption)\r\n- [Tier resolution (MaxActiveTier)](#tier-resolution-maxactivetier)\r\n- [Trial rules](#trial-rules)\r\n- [Purchase with virtual currency / items](#purchase-with-virtual-currency--items)\r\n- [Real-money IAP purchase — current backend status](#real-money-iap-purchase--current-backend-status)\r\n- [How other modules read a player's tier](#how-other-modules-read-a-players-tier)\r\n\r\n---\r\n\r\n## Player state\r\n\r\nReturned by `getUserState()` as `{ Premium: UserPremiumState }` and cached at\r\n`client.data.user.state?.Premium` (full replace on every write — see\r\n`applyPremium` in `packages/core/src/cache/UserData.ts:672`).\r\n\r\n```ts\r\ninterface UserPremiumState {\r\n Subscriptions?: Record<string, PremiumSubscription>; // key = PremiumID\r\n ActivatedTrialIDs?: string[]; // PremiumIDs already trialed — permanent, one-shot\r\n MaxActiveTier?: number; // highest Tier among currently-active subscriptions\r\n}\r\n\r\ninterface PremiumSubscription {\r\n PremiumID?: string;\r\n PurchaseDate?: string; // ISO; set on first purchase, or on renewal after a full lapse\r\n ExpirationDate?: string; // ISO (UTC); subscription is \"active\" iff this is strictly in the future\r\n TransactionID?: string; // last transaction that touched this subscription (idempotency key)\r\n IsAutoRenewEnabled?: boolean; // always false for trial/virtual purchases — see below\r\n}\r\n```\r\n\r\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/UserPremiumState.cs:13-40`.\r\n\r\nA subscription entry existing in `Subscriptions` does **not** mean it's\r\nactive — always compare `ExpirationDate` to \"now\" (or just trust\r\n`MaxActiveTier`, which the backend already recalculates for you on every\r\nread/write). Expired entries are never deleted; they're left in place so\r\n`ActivatedTrialIDs`-style history and renewal-on-top-of-lapsed logic keep\r\nworking. Don't build \"is subscribed\" UI off `Subscriptions[id]` existing —\r\ncheck its `ExpirationDate`, or better, read `MaxActiveTier`.\r\n\r\n---\r\n\r\n## Config: PremiumDefinitions\r\n\r\nReturned by `getDefinitions()`; cached via\r\n`client.data.config.getSection<PremiumDefinitions>(\"Premium\")`.\r\n\r\n```ts\r\ninterface PremiumDefinitions {\r\n Definitions?: Record<string, PremiumDefinition>; // key = PremiumID\r\n}\r\n```\r\n\r\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:20-26`.\r\n\r\n---\r\n\r\n## PremiumDefinition\r\n\r\nSelf-contained template for one subscription / premium pass / VIP tier.\r\n\r\n```ts\r\ninterface PremiumDefinition {\r\n PremiumID?: string; // stable id, e.g. \"silver_vip\" — never renamed after publish\r\n DisplayName?: string;\r\n Tier?: number; // 1, 2, 3... higher = more premium; compared against MinPremiumTier gates\r\n DurationDays?: number; // subscription length; 0 = permanent, 30 = monthly, 365 = yearly\r\n TrialDurationDays?: number; // 0 = no trial available for this tier\r\n PriceOptions?: Record<string, PriceOption>; // key = OptionID, e.g. \"Default\"\r\n Benefits?: Record<string, string>; // free-form slug -> stringified numeric param, for display only\r\n}\r\n```\r\n\r\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:35-108`.\r\n\r\n- **`Tier`** is the number every other module's gate compares against\r\n (`SegmentGate.MinPremiumTier`, `ResourceConsume.PremiumTiers` /\r\n `ResourceGrant.PremiumTiers` entries' `MinPremiumTier`, and any\r\n `RequiredPremiumID` variants of the same gate — see\r\n [How other modules read a player's tier](#how-other-modules-read-a-players-tier)).\r\n- **`DurationDays: 0`** means \"permanent\" — the backend actually implements\r\n this as expiring **100 years** from purchase (`ComputePurchase`,\r\n `IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:211`:\r\n `now.AddYears(100)`), not a literal null-expiration sentinel. Treat any\r\n `ExpirationDate` more than a few decades out as \"effectively permanent\" in\r\n UI, but don't special-case `0`/`null` yourself — always compare the actual\r\n `ExpirationDate`.\r\n- **`Benefits`** is display-only free-form data (e.g. `\"ExpMult\": \"1.2\"`,\r\n `\"NoAds\": \"1.0\"`). The SDK does not interpret these keys — a title defines\r\n its own vocabulary and its own game code reads them for copy/UI. They are\r\n **not** the mechanism that actually grants discounts/multipliers/gates —\r\n those are wired up server-side through `ResourceConsume.PremiumDiscounts` /\r\n `PremiumTiers`, `ResourceGrant.PremiumTiers`, and `SegmentGate.MinPremiumTier`\r\n independently of `Benefits`.\r\n\r\n---\r\n\r\n## PriceOption\r\n\r\nOne payment option within a `PremiumDefinition.PriceOptions` map — the\r\nplatform-wide price shape, identical in every module (see the `checkout-system`\r\nskill).\r\n\r\n```ts\r\ninterface PriceOption {\r\n OptionID?: string; // key within PriceOptions, e.g. \"Default\", \"bundle_a\"\r\n Name?: string; // optional display name, e.g. \"For Gold\"\r\n Cost?: ResourceConsume; // debit-only cost; see below\r\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n⚠ **A store-paid subscription does NOT go through this endpoint.** Renewals and\r\nrevocations arrive as server notifications from the store with no client request\r\nto attach them to, so a `Purchase` entry in a premium price is rejected with\r\n`\"Store-paid subscriptions go through the Purchase module (ValidatePurchase), not\r\nthrough PurchaseWithResources.\"` — use `client.purchase` for those.\r\n\r\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:117-140`.\r\n\r\n`Cost` is a standard `ResourceConsume`\r\n(`packages/core/src/models/_shared/ResourceModels.ts`) — cost lives in\r\n`Cost.Standard.Entries` (items/currencies) and/or\r\n`Cost.Standard.EventTokens`. **`purchaseItemOrCurrency` requires\r\nat least one of those two to be non-empty** — the backend rejects the call\r\noutright with `\"This purchase option has no resource cost. Real-money\r\nflow is not supported by this endpoint.\"` if both are empty (this is how the\r\nserver tells apart a virtual-cost option from a real-money-only one; see\r\n[Real-money IAP purchase](#real-money-iap-purchase--current-backend-status)).\r\n`Cost` may also declare `PremiumDiscounts` — if present, the\r\nbackend auto-applies the player's own best tier discount when charging, so\r\nthe amount actually debited can be lower than the raw `Amount` shown in the\r\noption (same mechanism documented in character-system's stat-cost formulas).\r\n\r\nSource of the rejection string: `IDosGamesSDK/API/Client/v2/Premium/Premium.cs:268`.\r\n\r\n---\r\n\r\n## Tier resolution (MaxActiveTier)\r\n\r\n`MaxActiveTier` is **not** stored independently — it's recomputed by\r\n`PremiumHelpers.RecalculateMaxTier` every time subscriptions change or are\r\nread (`IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:122-144`):\r\n\r\n1. Walk every entry in `Subscriptions`.\r\n2. Skip any whose `ExpirationDate <= now` (UTC) — expired subscriptions are\r\n silently ignored, never physically removed.\r\n3. Skip any `PremiumID` no longer present in the title's `Definitions` (a\r\n tier that was deleted/renamed from config after the player subscribed).\r\n4. `MaxActiveTier` = the highest `Tier` among what's left; `0` if nothing\r\n qualifies.\r\n\r\nThis runs on `GetUserState`, `ActivateTrial`, and\r\n`PurchaseWithResources` — so `MaxActiveTier` is always self-healing: even if\r\nsubscriptions expire between calls, the very next `getUserState()` (or any\r\npurchase/trial call) corrects it and persists the correction\r\n(`NormalizePremiumState`, `IDosGamesSDK/API/Client/v2/Premium/Premium.cs:391-409`).\r\n**Tiers don't stack** — holding two active subscriptions doesn't add their\r\ntiers together, it just takes the max.\r\n\r\nA separate helper, `PremiumHelpers.HasRequiredPremium`\r\n(`IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:44-55`), is\r\nwhat other modules' gate checks actually call server-side:\r\n\r\n- If a gate specifies a `RequiredPremiumID`, it checks only whether that\r\n exact `PremiumID` has an active subscription — **the tier number is\r\n ignored** in this branch (owning a specific pass matters, not its rank).\r\n- Otherwise, if the gate specifies `MinPremiumTier > 0`, it checks\r\n `MaxActiveTier >= MinPremiumTier`.\r\n- If neither is specified, the gate passes for everyone.\r\n\r\nThis is why `SegmentGate` and the resource-bundle gate types below expose\r\n**both** `MinPremiumTier` and `RequiredPremiumID`/`RequiredPremiumIDs` —\r\ntitles choose per-gate whether \"any tier ≥ N\" or \"must own this exact pass\"\r\nis the right check.\r\n\r\n---\r\n\r\n## Trial rules\r\n\r\n`activateTrial(premiumID, transactionID)` → backend `ActivateTrial`\r\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:140-238`). Checks, in order:\r\n\r\n1. `PremiumID` must be a safe Mongo key (no `.` or `$`) — else\r\n `\"Invalid PremiumID\"`.\r\n2. `TransactionID` is required — else `\"TransactionID is required\"`.\r\n3. The tier's definition must exist — else `\"Premium definition not found\"`.\r\n4. **`TrialDurationDays` must be `> 0`** — else\r\n `\"Trial is not available for this premium.\"` Not every tier offers a\r\n trial; check `TrialDurationDays` before showing a trial CTA.\r\n5. **Idempotent replay**: if the player already has a `Subscriptions[premiumID]`\r\n entry whose `TransactionID` matches the one just sent, the call returns\r\n the existing subscription unchanged (no new trial, no error) — this is\r\n what makes retrying a dropped request safe.\r\n6. **One trial per `PremiumID` per account, forever**: if `premiumID` is\r\n already in `ActivatedTrialIDs`, the call fails with\r\n `\"Trial already used.\"` This list is never cleared — cancelling a trial,\r\n letting it expire, or unsubscribing does not remove the id, so a player\r\n can never get a second free trial of the same tier from this endpoint.\r\n7. If the player has a _currently active_ (non-expired) subscription to that\r\n same `PremiumID` already, the call fails with\r\n `\"Subscription already active.\"` — you can't \"trial\" on top of an\r\n existing live subscription.\r\n8. On success: a new `PremiumSubscription` is created with\r\n `ExpirationDate = now + TrialDurationDays`, `IsAutoRenewEnabled: false`,\r\n `premiumID` is appended to `ActivatedTrialIDs`, and `MaxActiveTier` is\r\n recalculated. **No resources are consumed or granted** —\r\n `PremiumPurchaseResponse.Resources` comes back as an empty\r\n `ResourceOperation` (`Resources: new()`), never `null`, for this call.\r\n\r\nExact rejection strings (verbatim, from `Premium.cs`):\r\n`\"Invalid PremiumID\"` (line 149), `\"TransactionID is required\"` (line 150),\r\n`\"Premium definition not found\"` (line 154),\r\n`\"Trial is not available for this premium.\"` (line 156),\r\n`\"Trial already used.\"` (line 184),\r\n`\"Subscription already active.\"` (line 189),\r\n`\"User not found\"` (line 164), `\"Database update failed\"` (line 224).\r\n\r\n---\r\n\r\n## Purchase with virtual currency / items\r\n\r\n`purchaseItemOrCurrency(premiumID, transactionID, selectedOptionID, count)` →\r\nbackend `PurchaseWithResources`\r\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:240-389`, pure calc in\r\n`PremiumHelpers.ComputePurchase`, lines 180-255). Checks and behavior, in\r\norder:\r\n\r\n1. `PremiumID` safe-key check, `TransactionID` required — same errors as\r\n trial.\r\n2. Definition must exist (`\"Premium definition not found\"`) and\r\n `selectedOptionID` (default `\"Default\"`) must resolve to a configured\r\n `PriceOptions` entry (`\"PriceOption not found\"`).\r\n3. The option's `Cost` must carry at least one item/currency\r\n entry or event-token entry — otherwise\r\n `\"This purchase option has no resource cost. Real-money flow is not\r\nsupported by this endpoint.\"` (this endpoint is virtual-cost only; see\r\n next section for real money).\r\n4. **Idempotent replay** (`ComputePurchase`): if `Subscriptions[premiumID]`\r\n already has this exact `TransactionID`, the call returns the current\r\n state with **no charge** — safe retry.\r\n5. **Renewal stacking, not tier stacking**: if the player already has an\r\n active (non-expired) subscription to the _same_ `PremiumID`, the new\r\n duration is added **on top of** the existing `ExpirationDate` rather than\r\n from `now` (`baseTime = existingSub.ExpirationDate` when it's still in the\r\n future). Buying tier X while X is already active extends it; it does not\r\n reset the clock or double-grant. `PurchaseDate` is only updated when\r\n there was no prior subscription or the prior one had fully expired.\r\n6. `count` (default 1, clamped to minimum 1) multiplies `DurationDays` when\r\n computing the new expiration (`baseTime.AddDays(DurationDays * count)`) —\r\n there's no separate \"quantity\" concept beyond stretching the duration.\r\n `DurationDays <= 0` still resolves to the fixed `+100 years`, ignoring\r\n `count`.\r\n7. **Charge and write are atomic together**: the resource debit\r\n (`Cost`, with the player's own `PremiumDiscounts` applied\r\n automatically if configured) and the subscription write happen in the\r\n same `ResourceService.ApplyResourceOperationAtomicAsync` call, guarded\r\n additionally by a Mongo filter that rejects the write if a subscription\r\n with this `TransactionID` already exists at write time (defense-in-depth\r\n against double-charging beyond the idempotency-key check). Idempotency\r\n key used: `PremiumPurchase:<transactionID-or-derived>` (via\r\n `ResourceService.ResolveRelatedEntityID`).\r\n8. On success, `Resources` in the response is the actual `ResourceOperation`\r\n result of the debit (what was consumed, post-discount) — read updated\r\n balances from the cache, not by re-deriving the discount yourself.\r\n\r\nExact rejection strings (verbatim): `\"Invalid PremiumID\"`,\r\n`\"TransactionID is required\"`, `\"Premium definition not found\"`,\r\n`\"PriceOption not found\"` (line 261),\r\n`\"This purchase option has no resource cost. Real-money flow is not\r\nsupported by this endpoint.\"` (line 268), `\"User not found\"` (line 281),\r\n`\"Purchase failed: {result.Error}\"` (line 375, where `{result.Error}` is\r\nwhatever `ResourceService` reports — e.g. insufficient funds).\r\n\r\n---\r\n\r\n## Real-money IAP purchase — current backend status\r\n\r\nThe SDK's `purchaseRealMoney(...)` method sends `PremiumAction.PurchaseRealMoney`\r\nto `v2/{titleID}/Client/Premium/PurchaseRealMoney/{userID}`\r\n(`packages/core/src/api/PremiumApi.ts:78-86`, action enum in\r\n`PremiumModels.ts:130`). **As of this read, the v2 `Premium.cs` HTTP handler's\r\nswitch statement does not implement this action** — its `switch (act)` only\r\nhas cases for `GetDefinitions`, `GetUserState`, `ActivateTrial`, and\r\n`PurchaseWithResources`\r\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:52-69`); anything else\r\n(including `PurchaseRealMoney`) falls through to\r\n`default: return new BadRequestObjectResult(OperationResult<object>.Fail(\"Action not implemented\"))`\r\n(line 67).\r\n\r\nPractical implications for a consumer right now:\r\n\r\n- Calling `client.premium.purchaseRealMoney(...)` will resolve with\r\n `{ ok: false, reason: \"server\", error: \"Action not implemented\" }` against\r\n the current backend — it is **not** wired to any App Store/Google Play\r\n receipt validator in v2.\r\n- Real-money IAP receipt validation does exist elsewhere in the backend, but\r\n only in the **legacy v1** surface (`IDosGamesSDK/API/Client/v1/ValidateIAP.cs`,\r\n `ValidateIAPSubscription.cs`) — that is a different endpoint family, not\r\n reachable through `client.premium`, and out of scope for this module.\r\n- Do not build a shipping IAP-subscription flow against `purchaseRealMoney`\r\n until the backend gains a real handler for this action. If a title needs\r\n real-money subscriptions today, that requires a backend change outside the\r\n TS SDK's control — flag it rather than working around it client-side.\r\n\r\nThe method, request fields, and response shape are still documented below\r\nfor completeness (and because the shape is stable/forward-compatible once the\r\nbackend does implement it), but treat this whole section as **\"designed, not\r\nyet backed\"** rather than a working call.\r\n\r\nRequest fields sent by `purchaseRealMoney(premiumID, transactionID, store,\r\nproductID, receiptData, purchaseToken?, packageName?, appStoreEnvironment?)`:\r\n\r\n```ts\r\ninterface PremiumRequest {\r\n PremiumID: string;\r\n TransactionID: string;\r\n Store: \"Apple\" | \"Google\"; // StoreType\r\n ProductID: string; // SKU of the store product (v2: Purchase.Products[*].StoreProductIDs)\r\n ReceiptData: string; // base64 receipt (Apple) or receipt payload (Google)\r\n PurchaseToken?: string; // Google Play Billing purchase token\r\n PackageName?: string; // optional extra context\r\n AppStoreEnvironment?: string; // optional: e.g. distinguishing sandbox vs production\r\n}\r\n```\r\n\r\n`client`-side validation in `PremiumService.purchaseRealMoney` requires\r\n`premiumID`/`transactionID` (`\"PremiumID and TransactionID are required.\"`)\r\nand `productID`/`receiptData`\r\n(`\"ProductID and ReceiptData are required.\"`) before it will even attempt\r\nthe call (`packages/core/src/services/PremiumService.ts:107-111`) — those are\r\n`reason: \"client\"` failures, not server rejections.\r\n\r\n---\r\n\r\n## How other modules read a player's tier\r\n\r\nPremium's own state (`MaxActiveTier`, active `Subscriptions`) is a\r\ncross-module dependency. Other modules declare gates/bonuses that reference\r\nit; **this skill documents only the shape Premium exposes**, not how those\r\nother modules apply it (that's each module's own skill):\r\n\r\n- `SegmentGate.MinPremiumTier` / `SegmentGate.RequiredPremiumIDs`\r\n (`packages/core/src/models/_shared/SegmentModels.ts:27-28`) — audience\r\n gating used across Store/Quest/DealOffer/etc.\r\n- `ResourceConsume.PremiumDiscounts` / `ResourceConsume.PremiumTiers`\r\n and `ResourceGrant.PremiumTiers`\r\n (`packages/core/src/models/_shared/ResourceModels.ts:37-54`), each entry a\r\n `PremiumTierBundle { MinPremiumTier?, RequiredPremiumID?, Resources? }` —\r\n cost discounts / bonus grants scaled by tier, resolved entirely\r\n server-side inside `ResourceService`.\r\n- Reward accrual multipliers, e.g. `PremiumTierMultiplier\r\n{ MinPremiumTier?, RequiredPremiumID?, Multiplier? }`\r\n (`packages/core/src/models/reward/RewardModels.ts:192-197`) and\r\n `ClaimLimitOverride` tier overrides (same file, line 283+).\r\n- Ad-reduction perks, e.g. `PremiumAdReduction { MinPremiumTier?,\r\nRequiredPremiumID?, ... }` (`packages/core/src/models/advertising/AdvertisingModels.ts:110-115`).\r\n\r\nAll of these follow the same two-field pattern documented in\r\n[Tier resolution](#tier-resolution-maxactivetier): `RequiredPremiumID` (exact\r\npass, tier ignored) takes precedence when present, otherwise\r\n`MinPremiumTier` is compared against `MaxActiveTier`. Client-side, use\r\n`MaxActiveTier` only to preview/gray-out UI — the actual discount/bonus is\r\ncomputed and applied server-side inside that other call's own response.\r\n"
|
|
8
|
+
"content": "# Premium data model — reference\n\nFull shape of the config (Definitions) and player state, the tier-resolution\nand trial rules the backend enforces, and the purchase/receipt flow. All of\nthese are **strictly typed in the SDK** — `PremiumDefinitions` and its nested\nblocks (`PremiumDefinition`, `PriceOption`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<PremiumDefinitions>(\"Premium\")` 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\nbackend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: PremiumDefinitions](#config-premiumdefinitions) — what `getDefinitions()` returns\n- [PremiumDefinition](#premiumdefinition)\n- [PriceOption](#priceoption)\n- [Tier resolution (MaxActiveTier)](#tier-resolution-maxactivetier)\n- [Trial rules](#trial-rules)\n- [Purchase with virtual currency / items](#purchase-with-virtual-currency--items)\n- [Real-money IAP purchase — current backend status](#real-money-iap-purchase--current-backend-status)\n- [How other modules read a player's tier](#how-other-modules-read-a-players-tier)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Premium: UserPremiumState }` and cached at\n`client.data.user.state?.Premium` (full replace on every write — see\n`applyPremium` in `packages/core/src/cache/UserData.ts:672`).\n\n```ts\ninterface UserPremiumState {\n Subscriptions?: Record<string, PremiumSubscription>; // key = PremiumID\n ActivatedTrialIDs?: string[]; // PremiumIDs already trialed — permanent, one-shot\n MaxActiveTier?: number; // highest Tier among currently-active subscriptions\n}\n\ninterface PremiumSubscription {\n PremiumID?: string;\n PurchaseDate?: string; // ISO; set on first purchase, or on renewal after a full lapse\n ExpirationDate?: string; // ISO (UTC); subscription is \"active\" iff this is strictly in the future\n TransactionID?: string; // last transaction that touched this subscription (idempotency key)\n IsAutoRenewEnabled?: boolean; // always false for trial/virtual purchases — see below\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/UserPremiumState.cs:13-40`.\n\nA subscription entry existing in `Subscriptions` does **not** mean it's\nactive — always compare `ExpirationDate` to \"now\" (or just trust\n`MaxActiveTier`, which the backend already recalculates for you on every\nread/write). Expired entries are never deleted; they're left in place so\n`ActivatedTrialIDs`-style history and renewal-on-top-of-lapsed logic keep\nworking. Don't build \"is subscribed\" UI off `Subscriptions[id]` existing —\ncheck its `ExpirationDate`, or better, read `MaxActiveTier`.\n\n---\n\n## Config: PremiumDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<PremiumDefinitions>(\"Premium\")`.\n\n```ts\ninterface PremiumDefinitions {\n Definitions?: Record<string, PremiumDefinition>; // key = PremiumID\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:20-26`.\n\n---\n\n## PremiumDefinition\n\nSelf-contained template for one subscription / premium pass / VIP tier.\n\n```ts\ninterface PremiumDefinition {\n PremiumID?: string; // stable id, e.g. \"silver_vip\" — never renamed after publish\n DisplayName?: string;\n Tier?: number; // 1, 2, 3... higher = more premium; compared against MinPremiumTier gates\n DurationDays?: number; // subscription length; 0 = permanent, 30 = monthly, 365 = yearly\n TrialDurationDays?: number; // 0 = no trial available for this tier\n PriceOptions?: Record<string, PriceOption>; // key = OptionID, e.g. \"Default\"\n Benefits?: Record<string, string>; // free-form slug -> stringified numeric param, for display only\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:35-108`.\n\n- **`Tier`** is the number every other module's gate compares against\n (`SegmentGate.MinPremiumTier`, `ResourceConsume.PremiumTiers` /\n `ResourceGrant.PremiumTiers` entries' `MinPremiumTier`, and any\n `RequiredPremiumID` variants of the same gate — see\n [How other modules read a player's tier](#how-other-modules-read-a-players-tier)).\n- **`DurationDays: 0`** means \"permanent\" — the backend actually implements\n this as expiring **100 years** from purchase (`ComputePurchase`,\n `IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:211`:\n `now.AddYears(100)`), not a literal null-expiration sentinel. Treat any\n `ExpirationDate` more than a few decades out as \"effectively permanent\" in\n UI, but don't special-case `0`/`null` yourself — always compare the actual\n `ExpirationDate`.\n- **`Benefits`** is display-only free-form data (e.g. `\"ExpMult\": \"1.2\"`,\n `\"NoAds\": \"1.0\"`). The SDK does not interpret these keys — a title defines\n its own vocabulary and its own game code reads them for copy/UI. They are\n **not** the mechanism that actually grants discounts/multipliers/gates —\n those are wired up server-side through `ResourceConsume.PremiumDiscounts` /\n `PremiumTiers`, `ResourceGrant.PremiumTiers`, and `SegmentGate.MinPremiumTier`\n independently of `Benefits`.\n\n---\n\n## PriceOption\n\nOne payment option within a `PremiumDefinition.PriceOptions` map — the\nplatform-wide price shape, identical in every module (see the `checkout-system`\nskill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // key within PriceOptions, e.g. \"Default\", \"bundle_a\"\n Name?: string; // optional display name, e.g. \"For Gold\"\n Cost?: ResourceConsume; // debit-only cost; see below\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A store-paid subscription does NOT go through this endpoint.** Renewals and\nrevocations arrive as server notifications from the store with no client request\nto attach them to, so a `Purchase` entry in a premium price is rejected with\n`\"Store-paid subscriptions go through the Purchase module (ValidatePurchase), not\nthrough PurchaseWithResources.\"` — use `client.purchase` for those.\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:117-140`.\n\n`Cost` is a standard `ResourceConsume`\n(`packages/core/src/models/_shared/ResourceModels.ts`) — cost lives in\n`Cost.Standard.Entries` (items/currencies) and/or\n`Cost.Standard.EventTokens`. **`purchaseItemOrCurrency` requires\nat least one of those two to be non-empty** — the backend rejects the call\noutright with `\"This purchase option has no resource cost. Real-money\nflow is not supported by this endpoint.\"` if both are empty (this is how the\nserver tells apart a virtual-cost option from a real-money-only one; see\n[Real-money IAP purchase](#real-money-iap-purchase--current-backend-status)).\n`Cost` may also declare `PremiumDiscounts` — if present, the\nbackend auto-applies the player's own best tier discount when charging, so\nthe amount actually debited can be lower than the raw `Amount` shown in the\noption (same mechanism documented in character-system's stat-cost formulas).\n\nSource of the rejection string: `IDosGamesSDK/API/Client/v2/Premium/Premium.cs:268`.\n\n---\n\n## Tier resolution (MaxActiveTier)\n\n`MaxActiveTier` is **not** stored independently — it's recomputed by\n`PremiumHelpers.RecalculateMaxTier` every time subscriptions change or are\nread (`IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:122-144`):\n\n1. Walk every entry in `Subscriptions`.\n2. Skip any whose `ExpirationDate <= now` (UTC) — expired subscriptions are\n silently ignored, never physically removed.\n3. Skip any `PremiumID` no longer present in the title's `Definitions` (a\n tier that was deleted/renamed from config after the player subscribed).\n4. `MaxActiveTier` = the highest `Tier` among what's left; `0` if nothing\n qualifies.\n\nThis runs on `GetUserState`, `ActivateTrial`, and\n`PurchaseWithResources` — so `MaxActiveTier` is always self-healing: even if\nsubscriptions expire between calls, the very next `getUserState()` (or any\npurchase/trial call) corrects it and persists the correction\n(`NormalizePremiumState`, `IDosGamesSDK/API/Client/v2/Premium/Premium.cs:391-409`).\n**Tiers don't stack** — holding two active subscriptions doesn't add their\ntiers together, it just takes the max.\n\nA separate helper, `PremiumHelpers.HasRequiredPremium`\n(`IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:44-55`), is\nwhat other modules' gate checks actually call server-side:\n\n- If a gate specifies a `RequiredPremiumID`, it checks only whether that\n exact `PremiumID` has an active subscription — **the tier number is\n ignored** in this branch (owning a specific pass matters, not its rank).\n- Otherwise, if the gate specifies `MinPremiumTier > 0`, it checks\n `MaxActiveTier >= MinPremiumTier`.\n- If neither is specified, the gate passes for everyone.\n\nThis is why `SegmentGate` and the resource-bundle gate types below expose\n**both** `MinPremiumTier` and `RequiredPremiumID`/`RequiredPremiumIDs` —\ntitles choose per-gate whether \"any tier ≥ N\" or \"must own this exact pass\"\nis the right check.\n\n---\n\n## Trial rules\n\n`activateTrial(premiumID, transactionID)` → backend `ActivateTrial`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:140-238`). Checks, in order:\n\n1. `PremiumID` must be a safe Mongo key (no `.` or `$`) — else\n `\"Invalid PremiumID\"`.\n2. `TransactionID` is required — else `\"TransactionID is required\"`.\n3. The tier's definition must exist — else `\"Premium definition not found\"`.\n4. **`TrialDurationDays` must be `> 0`** — else\n `\"Trial is not available for this premium.\"` Not every tier offers a\n trial; check `TrialDurationDays` before showing a trial CTA.\n5. **Idempotent replay**: if the player already has a `Subscriptions[premiumID]`\n entry whose `TransactionID` matches the one just sent, the call returns\n the existing subscription unchanged (no new trial, no error) — this is\n what makes retrying a dropped request safe.\n6. **One trial per `PremiumID` per account, forever**: if `premiumID` is\n already in `ActivatedTrialIDs`, the call fails with\n `\"Trial already used.\"` This list is never cleared — cancelling a trial,\n letting it expire, or unsubscribing does not remove the id, so a player\n can never get a second free trial of the same tier from this endpoint.\n7. If the player has a _currently active_ (non-expired) subscription to that\n same `PremiumID` already, the call fails with\n `\"Subscription already active.\"` — you can't \"trial\" on top of an\n existing live subscription.\n8. On success: a new `PremiumSubscription` is created with\n `ExpirationDate = now + TrialDurationDays`, `IsAutoRenewEnabled: false`,\n `premiumID` is appended to `ActivatedTrialIDs`, and `MaxActiveTier` is\n recalculated. **No resources are consumed or granted** —\n `PremiumPurchaseResponse.Resources` comes back as an empty\n `ResourceOperation` (`Resources: new()`), never `null`, for this call.\n\nExact rejection strings (verbatim, from `Premium.cs`):\n`\"Invalid PremiumID\"` (line 149), `\"TransactionID is required\"` (line 150),\n`\"Premium definition not found\"` (line 154),\n`\"Trial is not available for this premium.\"` (line 156),\n`\"Trial already used.\"` (line 184),\n`\"Subscription already active.\"` (line 189),\n`\"User not found\"` (line 164), `\"Database update failed\"` (line 224).\n\n---\n\n## Purchase with virtual currency / items\n\n`purchaseItemOrCurrency(premiumID, transactionID, selectedOptionID, count)` →\nbackend `PurchaseWithResources`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:240-389`, pure calc in\n`PremiumHelpers.ComputePurchase`, lines 180-255). Checks and behavior, in\norder:\n\n1. `PremiumID` safe-key check, `TransactionID` required — same errors as\n trial.\n2. Definition must exist (`\"Premium definition not found\"`) and\n `selectedOptionID` (default `\"Default\"`) must resolve to a configured\n `PriceOptions` entry (`\"PriceOption not found\"`).\n3. The option's `Cost` must carry at least one item/currency\n entry or event-token entry — otherwise\n `\"This purchase option has no resource cost. Real-money flow is not\nsupported by this endpoint.\"` (this endpoint is virtual-cost only; see\n next section for real money).\n4. **Idempotent replay** (`ComputePurchase`): if `Subscriptions[premiumID]`\n already has this exact `TransactionID`, the call returns the current\n state with **no charge** — safe retry.\n5. **Renewal stacking, not tier stacking**: if the player already has an\n active (non-expired) subscription to the _same_ `PremiumID`, the new\n duration is added **on top of** the existing `ExpirationDate` rather than\n from `now` (`baseTime = existingSub.ExpirationDate` when it's still in the\n future). Buying tier X while X is already active extends it; it does not\n reset the clock or double-grant. `PurchaseDate` is only updated when\n there was no prior subscription or the prior one had fully expired.\n6. `count` (default 1, clamped to minimum 1) multiplies `DurationDays` when\n computing the new expiration (`baseTime.AddDays(DurationDays * count)`) —\n there's no separate \"quantity\" concept beyond stretching the duration.\n `DurationDays <= 0` still resolves to the fixed `+100 years`, ignoring\n `count`.\n7. **Charge and write are atomic together**: the resource debit\n (`Cost`, with the player's own `PremiumDiscounts` applied\n automatically if configured) and the subscription write happen in the\n same `ResourceService.ApplyResourceOperationAtomicAsync` call, guarded\n additionally by a Mongo filter that rejects the write if a subscription\n with this `TransactionID` already exists at write time (defense-in-depth\n against double-charging beyond the idempotency-key check). Idempotency\n key used: `PremiumPurchase:<transactionID-or-derived>` (via\n `ResourceService.ResolveRelatedEntityID`).\n8. On success, `Resources` in the response is the actual `ResourceOperation`\n result of the debit (what was consumed, post-discount) — read updated\n balances from the cache, not by re-deriving the discount yourself.\n\nExact rejection strings (verbatim): `\"Invalid PremiumID\"`,\n`\"TransactionID is required\"`, `\"Premium definition not found\"`,\n`\"PriceOption not found\"` (line 261),\n`\"This purchase option has no resource cost. Real-money flow is not\nsupported by this endpoint.\"` (line 268), `\"User not found\"` (line 281),\n`\"Purchase failed: {result.Error}\"` (line 375, where `{result.Error}` is\nwhatever `ResourceService` reports — e.g. insufficient funds).\n\n---\n\n## Real-money IAP purchase — current backend status\n\nThe SDK's `purchaseRealMoney(...)` method sends `PremiumAction.PurchaseRealMoney`\nto `v2/{titleID}/Client/Premium/PurchaseRealMoney/{userID}`\n(`packages/core/src/api/PremiumApi.ts:78-86`, action enum in\n`PremiumModels.ts:130`). **As of this read, the v2 `Premium.cs` HTTP handler's\nswitch statement does not implement this action** — its `switch (act)` only\nhas cases for `GetDefinitions`, `GetUserState`, `ActivateTrial`, and\n`PurchaseWithResources`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:52-69`); anything else\n(including `PurchaseRealMoney`) falls through to\n`default: return new BadRequestObjectResult(OperationResult<object>.Fail(\"Action not implemented\"))`\n(line 67).\n\nPractical implications for a consumer right now:\n\n- Calling `client.premium.purchaseRealMoney(...)` will resolve with\n `{ ok: false, reason: \"server\", error: \"Action not implemented\" }` against\n the current backend — it is **not** wired to any App Store/Google Play\n receipt validator in v2.\n- Real-money IAP receipt validation does exist elsewhere in the backend, but\n only in the **legacy v1** surface (`IDosGamesSDK/API/Client/v1/ValidateIAP.cs`,\n `ValidateIAPSubscription.cs`) — that is a different endpoint family, not\n reachable through `client.premium`, and out of scope for this module.\n- Do not build a shipping IAP-subscription flow against `purchaseRealMoney`\n until the backend gains a real handler for this action. If a title needs\n real-money subscriptions today, that requires a backend change outside the\n TS SDK's control — flag it rather than working around it client-side.\n\nThe method, request fields, and response shape are still documented below\nfor completeness (and because the shape is stable/forward-compatible once the\nbackend does implement it), but treat this whole section as **\"designed, not\nyet backed\"** rather than a working call.\n\nRequest fields sent by `purchaseRealMoney(premiumID, transactionID, store,\nproductID, receiptData, purchaseToken?, packageName?, appStoreEnvironment?)`:\n\n```ts\ninterface PremiumRequest {\n PremiumID: string;\n TransactionID: string;\n Store: \"Apple\" | \"Google\"; // StoreType\n ProductID: string; // SKU of the store product (v2: Purchase.Products[*].StoreProductIDs)\n ReceiptData: string; // base64 receipt (Apple) or receipt payload (Google)\n PurchaseToken?: string; // Google Play Billing purchase token\n PackageName?: string; // optional extra context\n AppStoreEnvironment?: string; // optional: e.g. distinguishing sandbox vs production\n}\n```\n\n`client`-side validation in `PremiumService.purchaseRealMoney` requires\n`premiumID`/`transactionID` (`\"PremiumID and TransactionID are required.\"`)\nand `productID`/`receiptData`\n(`\"ProductID and ReceiptData are required.\"`) before it will even attempt\nthe call (`packages/core/src/services/PremiumService.ts:107-111`) — those are\n`reason: \"client\"` failures, not server rejections.\n\n---\n\n## How other modules read a player's tier\n\nPremium's own state (`MaxActiveTier`, active `Subscriptions`) is a\ncross-module dependency. Other modules declare gates/bonuses that reference\nit; **this skill documents only the shape Premium exposes**, not how those\nother modules apply it (that's each module's own skill):\n\n- `SegmentGate.MinPremiumTier` / `SegmentGate.RequiredPremiumIDs`\n (`packages/core/src/models/_shared/SegmentModels.ts:27-28`) — audience\n gating used across Store/Quest/DealOffer/etc.\n- `ResourceConsume.PremiumDiscounts` / `ResourceConsume.PremiumTiers`\n and `ResourceGrant.PremiumTiers`\n (`packages/core/src/models/_shared/ResourceModels.ts:37-54`), each entry a\n `PremiumTierBundle { MinPremiumTier?, RequiredPremiumID?, Resources? }` —\n cost discounts / bonus grants scaled by tier, resolved entirely\n server-side inside `ResourceService`.\n- Reward accrual multipliers, e.g. `PremiumTierMultiplier\n{ MinPremiumTier?, RequiredPremiumID?, Multiplier? }`\n (`packages/core/src/models/reward/RewardModels.ts:192-197`) and\n `ClaimLimitOverride` tier overrides (same file, line 283+).\n- Ad-reduction perks, e.g. `PremiumAdReduction { MinPremiumTier?,\nRequiredPremiumID?, ... }` (`packages/core/src/models/advertising/AdvertisingModels.ts:110-115`).\n\nAll of these follow the same two-field pattern documented in\n[Tier resolution](#tier-resolution-maxactivetier): `RequiredPremiumID` (exact\npass, tier ignored) takes precedence when present, otherwise\n`MinPremiumTier` is compared against `MaxActiveTier`. Client-side, use\n`MaxActiveTier` only to preview/gray-out UI — the actual discount/bonus is\ncomputed and applied server-side inside that other call's own response.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "purchase-system",
|
|
3
3
|
"description": "Sell real-money in-app purchases (IAP) in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.purchase (PurchaseService): load the store product catalog with per-player availability, send a store receipt to the backend for verification, grant the product, restore purchases after a reinstall, and read the player's purchase state (ownership, counters, lifetime spend). Covers Apple App Store and Google Play receipts, consumables / non-consumables / subscriptions, and what happens when the store refunds a purchase. Use whenever the user wants real-money packs, \"remove ads\", a VIP subscription, a restore-purchases button, receipt validation, or touches client.purchase, PurchaseService, IapStore, ValidatePurchase, or IapProductDefinition — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: purchase-system\ndescription: >-\n Sell real-money in-app purchases (IAP) in a game on the iDosGames TypeScript\n SDK (@idosgames/core) via client.purchase (PurchaseService): load the store\n product catalog with per-player availability, send a store receipt to the\n backend for verification, grant the product, restore purchases after a\n reinstall, and read the player's purchase state (ownership, counters,\n lifetime spend). Covers Apple App Store and Google Play receipts, consumables\n / non-consumables / subscriptions, and what happens when the store refunds a\n purchase. Use whenever the user wants real-money packs, \"remove ads\", a VIP\n subscription, a restore-purchases button, receipt validation, or touches\n client.purchase, PurchaseService, IapStore, ValidatePurchase, or\n IapProductDefinition — even if they don't name the module explicitly.\n---\n\n# Purchase system — real money (iDosGames TS SDK)\n\nThe Purchase module is the title's **real-money** surface: the player pays in\nthe App Store or Google Play, the store hands your client a receipt, and the\nbackend verifies that receipt and grants the product. Everything about the\npayment itself belongs to the store; everything about what the player receives\nbelongs to the backend.\n\nOne fact shapes the whole module, and every rule below follows from it:\n\n> **The money is already paid before the server hears about the purchase.**\n\nSo a refusal here is not \"not enough funds\" — it is an **incident**. The player\nhas been charged. That is why every refusal is written to the title's\ntransaction ledger with a reason, why the storefront must hide products the\nserver would refuse, and why your client must keep handing a receipt to the\nbackend until it is accepted.\n\nThis skill is for **using** the production `PurchaseService`. If a call is\nrejected, that is the backend enforcing a rule (forged receipt, product not in\nthe catalog, purchase limit, audience gate) — surface it, don't try to\nreproduce the check client-side.\n\n## Not this module\n\nIf a store product is the **price of something else** — an offer inside a deal,\na lootbox opened for real money, a shop slot paid with an IAP — that purchase\ngoes through the owning module, not here. Use `client.checkout`\n(`CheckoutService`) for those. This module is for products that **are** the\ngoods.\n\nSubscriptions are bought here, but the
|
|
4
|
+
"content": "---\nname: purchase-system\ndescription: >-\n Sell real-money in-app purchases (IAP) in a game on the iDosGames TypeScript\n SDK (@idosgames/core) via client.purchase (PurchaseService): load the store\n product catalog with per-player availability, send a store receipt to the\n backend for verification, grant the product, restore purchases after a\n reinstall, and read the player's purchase state (ownership, counters,\n lifetime spend). Covers Apple App Store and Google Play receipts, consumables\n / non-consumables / subscriptions, and what happens when the store refunds a\n purchase. Use whenever the user wants real-money packs, \"remove ads\", a VIP\n subscription, a restore-purchases button, receipt validation, or touches\n client.purchase, PurchaseService, IapStore, ValidatePurchase, or\n IapProductDefinition — even if they don't name the module explicitly.\n---\n\n# Purchase system — real money (iDosGames TS SDK)\n\nThe Purchase module is the title's **real-money** surface: the player pays in\nthe App Store or Google Play, the store hands your client a receipt, and the\nbackend verifies that receipt and grants the product. Everything about the\npayment itself belongs to the store; everything about what the player receives\nbelongs to the backend.\n\nOne fact shapes the whole module, and every rule below follows from it:\n\n> **The money is already paid before the server hears about the purchase.**\n\nSo a refusal here is not \"not enough funds\" — it is an **incident**. The player\nhas been charged. That is why every refusal is written to the title's\ntransaction ledger with a reason, why the storefront must hide products the\nserver would refuse, and why your client must keep handing a receipt to the\nbackend until it is accepted.\n\nThis skill is for **using** the production `PurchaseService`. If a call is\nrejected, that is the backend enforcing a rule (forged receipt, product not in\nthe catalog, purchase limit, audience gate) — surface it, don't try to\nreproduce the check client-side.\n\n## Not this module\n\nIf a store product is the **price of something else** — an offer inside a deal,\na lootbox opened for real money, a shop slot paid with an IAP — that purchase\ngoes through the owning module, not here. Use `client.checkout`\n(`CheckoutService`) for those. This module is for products that **are** the\ngoods.\n\nSubscriptions are bought here, but the _entitlement_ they grant lives in the\nPremium module: read `client.premium` / `MaxActiveTier` to decide what a\nsubscriber may do. See the `premium-system` skill.\n\n## The three calls\n\n```ts\n// 1. What is on sale, and what may THIS player buy.\nconst defs = await client.purchase.getDefinitions();\n\n// 2. The player paid; hand the receipt over. Nothing is granted until this succeeds.\nconst result = await client.purchase.validatePurchase(store, receipt, {\n signature, // Google, when the store SDK reports it separately\n productID, // only for an opaque Apple app receipt\n transactionID, // Apple, see below — required with an opaque receipt\n});\n\n// 3. Reinstall / new device: hand over everything the store re-delivers.\nconst restored = await client.purchase.validatePurchasesBatch(receipts);\n```\n\n`getUserState()` returns the player's counters, ownership flags and lifetime\nspend when you need them outside a purchase.\n\n## The order you must not change\n\n```\nstore charges the player\n ↓\nstore hands you a receipt\n ↓\nvalidatePurchase() ← backend verifies and grants\n ↓\nONLY NOW: tell the store the transaction is finished\n```\n\nFinishing the transaction with the store before the backend accepted it turns\na network blip into a purchase the player paid for and will never receive.\nUnfinished orders are re-delivered by the store on the next launch — that is\nexactly what makes a crash mid-purchase recoverable. (Google goes further: an\nunacknowledged purchase is auto-refunded after three days.)\n\n## Apply rewards only when `Granted === true`\n\n`validatePurchase` resolves successfully in three different situations, and\nonly one of them granted anything:\n\n| `Status` | `Granted` | What happened |\n| ------------------ | --------- | -------------------------------------------------- |\n| `Granted` | `true` | Rewards were granted by this call |\n| `Restored` | `false` | Non-consumable already owned — ownership confirmed |\n| `AlreadyProcessed` | `false` | This receipt was already handled |\n\n`Resources` is an empty operation in the last two. The SDK applies it to the\nlocal cache for you and only when `Granted` is true — if you apply it yourself\nas well, one payment credits the reward twice.\n\n## Apple: pass `transactionID`\n\nUnity IAP and several other iOS wrappers hand you a **StoreKit 1 app receipt** —\nan opaque base64 blob with no transaction id inside. The backend asks Apple\nabout a purchase **by transaction id**, so with an opaque receipt it has nothing\nto ask about, and verification fails on a perfectly good purchase.\n\nPass `transactionID` whenever the store SDK reports one. It is ignored when the\nreceipt is a StoreKit 2 signed transaction (the id is inside), and unused for\nGoogle, where the purchase token inside the receipt plays the same role.\n\n`productID` follows the same rule and only that rule: with an opaque receipt the\nbackend cannot read the SKU either. In every other case the SKU comes **from the\nreceipt**, because a client's claim about what it bought is not evidence.\n\n## Availability is computed by the server — use it\n\n`getDefinitions()` returns `Availability` per product, and your storefront must\nrespect it:\n\n- `Available: false` with a `Reason` — do not offer the product. The gate, the\n sales window and the purchase limit are all enforced **at grant time**, i.e.\n after the player has paid. A product you show but the server refuses is a\n charged player with no goods and a support ticket.\n- `Owned: true` — a non-consumable the player already has. Show it as owned,\n not as buyable.\n- `Blocked: true` — the player refunded this product and the title's refund\n policy closed it for them. Permanent, and specific to this player: render it\n differently from \"temporarily unavailable\".\n\n## Refunds happen, and they change the player's state\n\nA refund arrives weeks later, without the client, and the backend applies the\ntitle's refund policy on its own. Depending on that policy the player may lose\nthe entitlement (a subscription expires, \"remove ads\" comes back), may have the\ngranted resources taken back — **including into a negative currency balance** —\nand may be blocked from buying that product again.\n\nWhat this means for your UI:\n\n- **Never treat a purchase as permanent client-side state.** Re-read\n `getUserState()` / `getDefinitions()` on launch and after returning from\n background; ownership can disappear.\n- **A negative balance is a legitimate state**, not a bug to clamp. It means the\n player owes: incoming grants pay the debt off before the balance rises. Render\n it honestly rather than showing `0`.\n- Items are never taken below zero, and event tokens are never taken back at\n all.\n\n## Restore\n\nApple requires a visible \"Restore purchases\" control; Google re-delivers\nautomatically. Both funnel into `validatePurchasesBatch`, which returns a\nper-receipt result: one forged or stale receipt does not cancel the other nine.\nNon-consumables come back as `Restored`; a consumable that never reached the\nbackend is granted now.\n\nEach item carries its own `Resources`, so apply per item — and again only where\n`Granted` is true.\n\n## Prices: show the store's, not ours\n\n`PriceUsdCents` in the catalog is the **declared tier** used for analytics and\nsorting. Display the localized price string the store SDK gives you: the store\nsells the local equivalent of the tier, and both platforms require their own\nprice to be the one shown to the player.\n\n## Full field-by-field shapes\n\n`references/data-model.md` — product/store definitions, the refund policy,\nthe user state, and the validation response, with the traps that are easy to\nget wrong.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Purchase — data model\n\nField-by-field shapes behind `client.purchase`. Types live in\n`@idosgames/core` → `models/purchase/PurchaseModels`. Every schema is\n`.passthrough()`, so a backend field newer than your SDK version survives\nparsing even when it is not typed here.\n\n---\n\n## Catalog — `PurchaseDefinitions`\n\nReturned by `getDefinitions()` together with per-player `Availability`.\n\n| Field | Meaning |\n|---|---|\n| `Enabled` | Master switch. `false` → the backend refuses every receipt regardless of product settings. |\n| `Products` | `Record<ProductID, IapProductDefinition>`. The key is **our** stable id, not the store SKU. |\n| `Stores` | Per-store settings (`\"GooglePlay\"` / `\"AppleAppStore\"`). Verification mode, package name. No secrets — they are addressed by name and never leave the server. |\n| `Validation` | Rules shared by all stores (sandbox, receipt age, batch size). |\n| `Refund` | Default refund policy for every product of the title. |\n| `Presets` | Reusable reward / rules / refund blocks referenced by products. |\n\n### `IapProductDefinition`\n\n| Field | Meaning |\n|---|---|\n| `ProductID` | Our id, and the key in `Products`. Never changes after publication — counters and ledger entries reference it. |\n| `Type` | `Consumable` \\| `NonConsumable` \\| `Subscription`. |\n| `Enabled` | On sale. `false` still allows restoring old receipts of a non-consumable. |\n| `StoreProductIDs` | `{ GooglePlay: sku, AppleAppStore: sku }`. This is how a receipt maps to our product: SKU inside the receipt → `ProductID`. |\n| `Rewards` | What the player gets. For a subscription this is the **welcome** grant on first activation only. |\n| `Subscription` | Premium binding: `PremiumID`, `RenewalRewards` (granted on every renewal), `FallbackDurationDays`. |\n| `PriceUsdCents` | Declared tier — analytics and sorting. **Not** what you display; show the store's localized price. |\n| `Rules` | `StartUtc` / `EndUtc` (sales window), `Gate` (audience), `Limits` (`TotalCap`, `DailyCap`). |\n| `Refund` | This product's refund policy. Unset = the title's. |\n\n⚠ Two products sharing a SKU **in the same store** is a configuration error: the\nbackend takes the first match and the per-product metrics split silently.\n\n### `IapProductAvailability`\n\nComputed per player, next to the catalog.\n\n| Field | Meaning |\n|---|---|\n| `Available` | Safe to offer. |\n| `Owned` | Non-consumable already owned (or subscription active). |\n| `Blocked` | Closed for this player after a refund — **permanent**, and not the same as `Available: false`. |\n| `Reason` | Why unavailable; `null` when available. |\n| `StoreProductIDs` | The SKUs to ask the store SDK for prices. |\n\nThe gate, window and limits behind `Reason` are enforced **at grant time** —\nafter the money is gone. A storefront that ignores `Available` produces charged\nplayers with no goods.\n\n---\n\n## Refund policy — `IapRefundPolicy`\n\nLives on the product, on a preset, and on the title. Resolution order:\n\n```\nproduct → product's preset → title default → platform default\n```\n\n⚠ **Every field is nullable, and `null` ≠ `false`.** `null` means \"inherit from\nthe level above\"; a set value — *including* `false` — is final and overrides the\nlevel above. A UI that renders these as two-state switches makes \"inherit\"\nunexpressible.\n\n| Field | Values | Platform default |\n|---|---|---|\n| `ResourceAction` | `Keep` \\| `Clawback` \\| `ClawbackForce` | `Keep` |\n| `RevokeEntitlement` | `true` / `false` | `true` (for subscriptions, the legacy `Subscription.RevokeOnRefund` is honoured when unset) |\n| `BlockFuturePurchases` | `true` / `false` | `false` |\n\n`ResourceAction`:\n\n- **`Keep`** — take nothing back; only record the refund and (if configured)\n revoke the entitlement.\n- **`Clawback`** — take back what the player still has, never below the floor.\n Spent it all? Nothing is taken and the refund still succeeds.\n- **`ClawbackForce`** — take the full amount, letting the **currency** balance go\n negative. The debt is paid off by later grants: while the balance is negative\n the player effectively receives nothing.\n\nTwo boundaries that are not obvious:\n\n- **Force applies to currencies only.** Items are always limited to what the\n player has — there is no negative item count, and an unbounded item deduction\n would fail the whole operation, taking the currency deduction with it.\n- **Event tokens are never clawed back.** Their bucket is addressed by the\n schedule instance of the event that granted them; weeks later that bucket no\n longer exists, and deducting from the current one would take points earned in\n a different event.\n\n---\n\n## Player state — `UserPurchaseState`\n\nReturned by `getUserState()`.\n\n| Field | Meaning |\n|---|---|\n| `Products` | `Record<ProductID, IapProductPurchaseState>` |\n| `Subscriptions` | Store-side mirror per product: expiry, auto-renew, status. The **entitlement** lives in Premium; this is what the store says. |\n| `LifetimeSpendUsdCents` | Accumulated from the declared price, not from receipt amounts — those are in the buyer's currency and cannot be summed. |\n| `TotalPurchases`, `FirstPurchaseAt`, `LastPurchaseAt` | Payer markers for segmentation. |\n\n### `IapProductPurchaseState`\n\n`TotalPurchases`, `DailyPurchases`, `DailyResetUtc`, `LastPurchasedAt`,\n`Owned`, `Refunded`, `PurchaseBlocked`.\n\n`Refunded` and `PurchaseBlocked` are different facts: a refund alone does not\nforbid buying again — only a policy with `BlockFuturePurchases` does.\n\n### Subscription mirror status\n\n`Active` | `Canceled` | `GracePeriod` | `Expired` | `Revoked`.\n\n`Canceled` means the player turned auto-renew off — **the period is still paid\nand access continues** until `ExpiresAt`. `GracePeriod` means the payment\nfailed but the store is still granting access while it retries. Treating either\nas \"no longer a subscriber\" cuts off a player who has not lost anything yet.\n\n---\n\n## Validation response — `PurchaseValidationResponse`\n\n| Field | Meaning |\n|---|---|\n| `Status` | `Granted` \\| `Restored` \\| `AlreadyProcessed` |\n| `Granted` | Rewards were granted **by this call**. The only flag worth branching on. |\n| `Resources` | The applied operation. Empty unless `Granted`. |\n| `TransactionID` | Store transaction — match the answer to your receipt. |\n| `ProductState` | Product counters after the operation, so no second round-trip. |\n| `Premium`, `Subscription`, `SubscriptionMirror` | Subscriptions only. |\n\nBatch (`validatePurchasesBatch`) returns per-receipt items keyed by transaction\nid (or the product id, when the receipt could not be parsed). There is **no**\nshared `Resources` at the batch level — each item carries its own, because each\nreceipt is applied in its own transaction and one bad receipt must not cancel\nthe rest.\n\n---\n\n## Verification modes (title config, for context)\n\nYou do not choose these from the client, but they explain the errors you see.\n\n| Mode | Store | Notes |\n|---|---|---|\n| `LocalSignature` | Google | RSA signature checked locally. No network. Blind to refunds. |\n| `SignedTransaction` | Apple | StoreKit 2 JWS with a certificate chain. No network. Blind to refunds. |\n| `StoreServer` | both | Asks the store's server API. Most authoritative. **Apple needs a transaction id** — see the `transactionID` argument. |\n| `LegacyReceipt` | Apple | Deprecated `verifyReceipt`. |\n| `Unverified` | both | Test bench only — any player can grant themselves anything. |\n\nRefunds and renewals are detected by the backend on its own schedule; the\nclient is never the source of that information and must not assume its cached\nstate is still true after a pause.\n"
|
|
8
|
+
"content": "# Purchase — data model\n\nField-by-field shapes behind `client.purchase`. Types live in\n`@idosgames/core` → `models/purchase/PurchaseModels`. Every schema is\n`.passthrough()`, so a backend field newer than your SDK version survives\nparsing even when it is not typed here.\n\n---\n\n## Catalog — `PurchaseDefinitions`\n\nReturned by `getDefinitions()` together with per-player `Availability`.\n\n| Field | Meaning |\n| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Enabled` | Master switch. `false` → the backend refuses every receipt regardless of product settings. |\n| `Products` | `Record<ProductID, IapProductDefinition>`. The key is **our** stable id, not the store SKU. |\n| `Stores` | Per-store settings (`\"GooglePlay\"` / `\"AppleAppStore\"`). Verification mode, package name. No secrets — they are addressed by name and never leave the server. |\n| `Validation` | Rules shared by all stores (sandbox, receipt age, batch size). |\n| `Refund` | Default refund policy for every product of the title. |\n| `Presets` | Reusable reward / rules / refund blocks referenced by products. |\n\n### `IapProductDefinition`\n\n| Field | Meaning |\n| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |\n| `ProductID` | Our id, and the key in `Products`. Never changes after publication — counters and ledger entries reference it. |\n| `Type` | `Consumable` \\| `NonConsumable` \\| `Subscription`. |\n| `Enabled` | On sale. `false` still allows restoring old receipts of a non-consumable. |\n| `StoreProductIDs` | `{ GooglePlay: sku, AppleAppStore: sku }`. This is how a receipt maps to our product: SKU inside the receipt → `ProductID`. |\n| `Rewards` | What the player gets. For a subscription this is the **welcome** grant on first activation only. |\n| `Subscription` | Premium binding: `PremiumID`, `RenewalRewards` (granted on every renewal), `FallbackDurationDays`. |\n| `PriceUsdCents` | Declared tier — analytics and sorting. **Not** what you display; show the store's localized price. |\n| `Rules` | `StartUtc` / `EndUtc` (sales window), `Gate` (audience), `Limits` (`TotalCap`, `DailyCap`). |\n| `Refund` | This product's refund policy. Unset = the title's. |\n\n⚠ Two products sharing a SKU **in the same store** is a configuration error: the\nbackend takes the first match and the per-product metrics split silently.\n\n### `IapProductAvailability`\n\nComputed per player, next to the catalog.\n\n| Field | Meaning |\n| ----------------- | ---------------------------------------------------------------------------------------------- |\n| `Available` | Safe to offer. |\n| `Owned` | Non-consumable already owned (or subscription active). |\n| `Blocked` | Closed for this player after a refund — **permanent**, and not the same as `Available: false`. |\n| `Reason` | Why unavailable; `null` when available. |\n| `StoreProductIDs` | The SKUs to ask the store SDK for prices. |\n\nThe gate, window and limits behind `Reason` are enforced **at grant time** —\nafter the money is gone. A storefront that ignores `Available` produces charged\nplayers with no goods.\n\n---\n\n## Refund policy — `IapRefundPolicy`\n\nLives on the product, on a preset, and on the title. Resolution order:\n\n```\nproduct → product's preset → title default → platform default\n```\n\n⚠ **Every field is nullable, and `null` ≠ `false`.** `null` means \"inherit from\nthe level above\"; a set value — _including_ `false` — is final and overrides the\nlevel above. A UI that renders these as two-state switches makes \"inherit\"\nunexpressible.\n\n| Field | Values | Platform default |\n| ---------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------- |\n| `ResourceAction` | `Keep` \\| `Clawback` \\| `ClawbackForce` | `Keep` |\n| `RevokeEntitlement` | `true` / `false` | `true` (for subscriptions, the legacy `Subscription.RevokeOnRefund` is honoured when unset) |\n| `BlockFuturePurchases` | `true` / `false` | `false` |\n\n`ResourceAction`:\n\n- **`Keep`** — take nothing back; only record the refund and (if configured)\n revoke the entitlement.\n- **`Clawback`** — take back what the player still has, never below the floor.\n Spent it all? Nothing is taken and the refund still succeeds.\n- **`ClawbackForce`** — take the full amount, letting the **currency** balance go\n negative. The debt is paid off by later grants: while the balance is negative\n the player effectively receives nothing.\n\nTwo boundaries that are not obvious:\n\n- **Force applies to currencies only.** Items are always limited to what the\n player has — there is no negative item count, and an unbounded item deduction\n would fail the whole operation, taking the currency deduction with it.\n- **Event tokens are never clawed back.** Their bucket is addressed by the\n schedule instance of the event that granted them; weeks later that bucket no\n longer exists, and deducting from the current one would take points earned in\n a different event.\n\n---\n\n## Player state — `UserPurchaseState`\n\nReturned by `getUserState()`.\n\n| Field | Meaning |\n| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| `Products` | `Record<ProductID, IapProductPurchaseState>` |\n| `Subscriptions` | Store-side mirror per product: expiry, auto-renew, status. The **entitlement** lives in Premium; this is what the store says. |\n| `LifetimeSpendUsdCents` | Accumulated from the declared price, not from receipt amounts — those are in the buyer's currency and cannot be summed. |\n| `TotalPurchases`, `FirstPurchaseAt`, `LastPurchaseAt` | Payer markers for segmentation. |\n\n### `IapProductPurchaseState`\n\n`TotalPurchases`, `DailyPurchases`, `DailyResetUtc`, `LastPurchasedAt`,\n`Owned`, `Refunded`, `PurchaseBlocked`.\n\n`Refunded` and `PurchaseBlocked` are different facts: a refund alone does not\nforbid buying again — only a policy with `BlockFuturePurchases` does.\n\n### Subscription mirror status\n\n`Active` | `Canceled` | `GracePeriod` | `Expired` | `Revoked`.\n\n`Canceled` means the player turned auto-renew off — **the period is still paid\nand access continues** until `ExpiresAt`. `GracePeriod` means the payment\nfailed but the store is still granting access while it retries. Treating either\nas \"no longer a subscriber\" cuts off a player who has not lost anything yet.\n\n---\n\n## Validation response — `PurchaseValidationResponse`\n\n| Field | Meaning |\n| ----------------------------------------------- | ------------------------------------------------------------------------ |\n| `Status` | `Granted` \\| `Restored` \\| `AlreadyProcessed` |\n| `Granted` | Rewards were granted **by this call**. The only flag worth branching on. |\n| `Resources` | The applied operation. Empty unless `Granted`. |\n| `TransactionID` | Store transaction — match the answer to your receipt. |\n| `ProductState` | Product counters after the operation, so no second round-trip. |\n| `Premium`, `Subscription`, `SubscriptionMirror` | Subscriptions only. |\n\nBatch (`validatePurchasesBatch`) returns per-receipt items keyed by transaction\nid (or the product id, when the receipt could not be parsed). There is **no**\nshared `Resources` at the batch level — each item carries its own, because each\nreceipt is applied in its own transaction and one bad receipt must not cancel\nthe rest.\n\n---\n\n## Verification modes (title config, for context)\n\nYou do not choose these from the client, but they explain the errors you see.\n\n| Mode | Store | Notes |\n| ------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- |\n| `LocalSignature` | Google | RSA signature checked locally. No network. Blind to refunds. |\n| `SignedTransaction` | Apple | StoreKit 2 JWS with a certificate chain. No network. Blind to refunds. |\n| `StoreServer` | both | Asks the store's server API. Most authoritative. **Apple needs a transaction id** — see the `transactionID` argument. |\n| `LegacyReceipt` | Apple | Deprecated `verifyReceipt`. |\n| `Unverified` | both | Test bench only — any player can grant themselves anything. |\n\nRefunds and renewals are detected by the backend on its own schedule; the\nclient is never the source of that information and must not assume its cached\nstate is still true after a pause.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "referral-system",
|
|
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 (who they're subscribed to, follower count, claimed invite rewards), activate someone else's
|
|
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 (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral 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.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. 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) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`.\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\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached 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- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\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### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\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 + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\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\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\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- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n 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- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\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",
|
|
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\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=...`) 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",
|
|
8
|
-
"content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n}\n```\n\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\nsupposed to call after a successful deduction to compute and grant the\nkickback — but grepping the entire backend turns up **zero** definitions or\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\nevent tied to a follower's purchase — there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\n // ⚠ FollowerIDs was REMOVED: an unbounded list of UserIDs inside the player document that\n // nothing read. Only the count remains. Activating a code also makes the two players friends,\n // so the identities are reachable as a friends list (client.social.getFriendsList()).\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface ReferralInviteRewardState {\n RewardID?: string; // == the MilestoneID key\n IsClaimed?: boolean;\n ClaimedAt?: string | null;\n}\n```\n\n`InviteRewardStates` only contains entries that have actually been claimed —\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\nmilestone systems); a milestone id absent from the map simply means \"not yet\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\n`RequiredProgress`.\n\nA player's referral code **is their own `UserID`** — the module has no\nseparate generated/short code. To let a player share \"their\" code, show them\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\nor endpoint for a display-friendly code.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\n point the curve is the **identity**, so a player who has not reached the first\n tier gets no bonus.\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\n real zero. `NaN`/`Infinity` collapses to `1.0`.\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\n the resolver: a reward multiplier never reduces a reward unless the publisher\n says so explicitly.\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\n `null`, the grant is returned unscaled.\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\n left alone — they're applied later, after scaling, inside\n `ResourceService`.\n - **Rounding**: each scaled amount goes through the platform-wide\n `ModifierService.Apply` with a `Multiply` step, which finishes with\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\nmultiplier)`, never negative, never silently truncated down.\n\nTo preview this on the client before the player claims, call\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\nthe exact same spec/progress/rounding server-side and returns\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\nown copy of this multiplier — it's title-wide, not per-module.\n\n---\n\n## Activation flow — server rules\n\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\nin order:\n\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\n the SDK side before this is even sent).\n2. Trimmed + uppercased. If it equals the caller's own `UserID` (also\n uppercased): `\"Cannot activate your own referral code\"`.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n (`FollowerIDs` no longer exists — only the count is kept).\n - The new referrer's `FollowersCount` is atomically incremented.\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\n two as mutual friends (capped by the Social module's friend limit;\n silently skipped if either side is already at the cap).\n - `IsFirstActivation` is `true` only when the caller had **no** previous\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\n When true, `ActivationReward` is granted via\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\n set permanently — a later referrer switch will not re-grant it.\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\n guarding against a concurrent change (matches \"no previous referrer\" or\n \"still the previously-read referrer\"); if that races, the call fails\n with `\"Referral state was modified concurrently. Please retry.\"` and the\n client should just retry.\n\n## Claim flow — server rules\n\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\norder:\n\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\n2. Must exist in `config.Referral.InviteRewards`, else\n `\"Invite reward '{id}' not found in configuration\"`.\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\n `\"Not enough followers. Required: {n}, current: {m}\"`.\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\n5. The resolved grant (see above) is applied atomically with idempotency key\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\n only allows the write when there's no existing claimed state for that\n reward id (protects against a double-claim race the same way step 3/4\n protect against a stale read).\n"
|
|
8
|
+
"content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Lootbox\", \"Reward\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Rate?: number; // FRACTION: 0.05 = 5%. Never 0..100. Empty or 0 = the rule pays nothing.\n Basis?: string; // \"Spend\" (default) or \"Earn\" — what the share is taken from\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n MinSourceAmount?: number; // ignore operations below this. 0 = no floor\n MaxRewardPerOperation?: number; // cap the payout from ONE operation. 0 = no cap\n Limits?: LimitSpec; // anti-farm caps for the REFERRER: DailyCap, DailyWeightCap\n}\n```\n\n⚠ **`FeatureKey` is not free text.** The accepted values are listed in\n`ReferralV2.SpendFeatureKeys`; an unknown key never matches, so the rule stays\nsilent rather than erroring. `\"Gacha\"` is **not** one of them — the lootbox key\nis `\"Lootbox\"`.\n\n⚠ **The field is `Rate` and it is a fraction.** It used to be `Percent` holding\n0..100. A dashboard that wrote the old shape had it dropped at deserialisation\nand every rule it created silently paid nobody — no error anywhere. If you\nrender a percentage, convert at the edge and keep the wire value a fraction.\n\n**This is wired and it pays.** (This section used to claim the opposite; that\nwas wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called from 12 modules\nafter a successful deduction. What remains true is the client-side\nconsequence: the referrer gets **no event and no cache update**, because the\npayout happens inside a request made by _another_ player. Their balance simply\nreflects it on their next own request. So describe the rule in UI (\"earn N%\nback when friends spend\"), but do not build a claim or notification flow — the\ngrant is automatic and there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n Code?: string | null; // THIS player's own short code, e.g. \"WDJBMJHT\". Minted lazily on the first getUserState()\n PendingActivationReward?: boolean; // bound at login, reward not paid yet — settled by the next getUserState()\n SubscribedToUserID?: string | null; // the referrer's USER ID (not their code); null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code\n // ⚠ FollowerIDs was REMOVED: an unbounded list of UserIDs inside the player document that\n // nothing read. Only the count remains. Activating a code also makes the two players friends,\n // so the identities are reachable as a friends list (client.social.getFriendsList()).\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface ReferralInviteRewardState {\n RewardID?: string; // == the MilestoneID key\n IsClaimed?: boolean;\n ClaimedAt?: string | null;\n}\n```\n\n`InviteRewardStates` only contains entries that have actually been claimed —\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\nmilestone systems); a milestone id absent from the map simply means \"not yet\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\n`RequiredProgress`.\n\n### The code and the invite link\n\nA player's code is a **short generated string**, not their `UserID`: 8\ncharacters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, so no accidental words; no\n`0/O`, no `1/I/L`, so nothing is misread when dictated). It lives in\n`UserReferralState.Code`.\n\n- **Minted lazily**, on the first `getUserState()`. Most players never invite\n anyone, and giving every registration a row in the code collection would be\n a write per signup for a field nobody reads.\n- **Scoped to the TITLE**, stored as `{titleID}:{code}`. Two titles may hand\n out the same code to different people, which is why every invite link\n carries the title.\n- The ready-made link comes back **on the response**, as\n `UserReferralStateResponse.InviteUrl` — `idosgames.com/go/{titleID}?ref={code}`.\n The server builds it; a client-built link from a template would be an open\n redirect. It is absent whenever `Code` is.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\n point the curve is the **identity**, so a player who has not reached the first\n tier gets no bonus.\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\n real zero. `NaN`/`Infinity` collapses to `1.0`.\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\n the resolver: a reward multiplier never reduces a reward unless the publisher\n says so explicitly.\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\n `null`, the grant is returned unscaled.\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\n left alone — they're applied later, after scaling, inside\n `ResourceService`.\n - **Rounding**: each scaled amount goes through the platform-wide\n `ModifierService.Apply` with a `Multiply` step, which finishes with\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\nmultiplier)`, never negative, never silently truncated down.\n\nTo preview this on the client before the player claims, call\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\nthe exact same spec/progress/rounding server-side and returns\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\nown copy of this multiplier — it's title-wide, not per-module.\n\n---\n\n## Activation flow — server rules\n\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\nin order:\n\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\n the SDK side before this is even sent).\n2. **Shape checked before any lookup**: 8 characters from\n `BCDFGHJKMNPQRSTVWXZ`, separators ignored, case-insensitive. Anything else\n — a `UserID` included — is `\"Referral code is invalid\"` without touching\n the database.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The normalised code must resolve to a real player in THIS title, else\n `\"Referral code is invalid\"`. Resolving to the caller themselves is\n `\"Cannot activate your own referral code\"` — the comparison is on ids,\n after the lookup, so pasting your own link gives the specific message\n rather than a generic one.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n (`FollowerIDs` no longer exists — only the count is kept).\n - The new referrer's `FollowersCount` is atomically incremented.\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\n two as mutual friends (capped by the Social module's friend limit;\n silently skipped if either side is already at the cap).\n - `IsFirstActivation` is `true` only when the caller had **no** previous\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\n When true, `ActivationReward` is granted via\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\n set permanently — a later referrer switch will not re-grant it.\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\n guarding against a concurrent change (matches \"no previous referrer\" or\n \"still the previously-read referrer\"); if that races, the call fails\n with `\"Referral state was modified concurrently. Please retry.\"` and the\n client should just retry.\n\n## Claim flow — server rules\n\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\norder:\n\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\n2. Must exist in `config.Referral.InviteRewards`, else\n `\"Invite reward '{id}' not found in configuration\"`.\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\n `\"Not enough followers. Required: {n}, current: {m}\"`.\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\n5. The resolved grant (see above) is applied atomically with idempotency key\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\n only allows the write when there's no existing claimed state for that\n reward id (protects against a double-claim race the same way step 3/4\n protect against a stale read).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Reward data model — reference\r\n\r\nFull shape of the config (Definitions) and player state for all four\r\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\r\nand the milestone reward multiplier curve. The config side is **strictly\r\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\r\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\r\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\r\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\r\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\r\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\r\nlater still round-trips. The deeper nested shapes shown below as plain\r\n`interface` blocks in this doc (`DailyCalendarDefinition`,\r\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\r\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\r\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\r\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\r\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\r\ntypes — most of them are **not individually exported by name** from\r\n`@idosgames/core`'s public entry point today; don't write `import type {\r\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\r\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\r\nstyle indexed-access types if you need the standalone name). Per-user state\r\nobjects beyond the top-level four dictionaries are typed as lenient\r\npassthrough shapes on the SDK side — the fields documented below are what the\r\nbackend actually puts on them. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\n## Contents\r\n\r\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\r\n- [Tier-reward settings](#tier-reward-settings)\r\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\r\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\r\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\r\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\r\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\r\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\r\n\r\n---\r\n\r\n## Root config: RewardDefinitions\r\n\r\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\r\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\r\n`RewardDefinitions.cs`.\r\n\r\n```ts\r\ninterface RewardDefinitions {\r\n TierRewards?: TierRewardSettings | null;\r\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\r\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\r\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\r\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\r\n Claims?: Record<string, ClaimRewardDefinition> | null;\r\n}\r\n```\r\n\r\nEach of the four dictionaries is an **independent subsystem** — a title can\r\nuse only some of them; an empty/absent dictionary just means that subsystem is\r\noff. All four grant rewards through the same `ResourceGrant`, so premium\r\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\r\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\r\n\r\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\r\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\r\n\r\n```ts\r\ninterface UserRewardState {\r\n DailyCalendars?: Record<string, UserDailyCalendarState>;\r\n IdleAccruals?: Record<string, UserIdleAccrualState>;\r\n Comebacks?: Record<string, UserComebackState>;\r\n Claims?: Record<string, UserClaimRewardState>;\r\n}\r\n```\r\n\r\nAn absent entry in any of the four dictionaries means \"player never touched\r\nthis ID\" — the server treats it as default/zero state, not an error.\r\n\r\n---\r\n\r\n## Tier-reward settings\r\n\r\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\r\nrewards resolve across _every_ system that has tiers (premium, season, battle\r\npass, etc.), not just Reward itself. One mode per title.\r\n\r\n```ts\r\ninterface TierRewardSettings {\r\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\r\n RewardStackLowerTiers?: boolean; // default: false\r\n}\r\n```\r\n\r\n- `Additive` — tier rewards are added **on top of** the base reward.\r\n- `Replace` — tier rewards **fully replace** the base reward.\r\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\r\n `false` (default) — only the best matching tier applies.\r\n\r\nThis block is read by `ResourceService`, not by Reward's own claim logic\r\ndirectly — it's here because `RewardDefinitions` is where it's configured.\r\n\r\n---\r\n\r\n## Daily calendars\r\n\r\n### Config: `DailyCalendarDefinition`\r\n\r\n```ts\r\ninterface DailyCalendarDefinition {\r\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\r\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\r\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\r\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\r\n MissThresholdMultiplier?: number; // default 2.0\r\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\r\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\r\n Gate?: SegmentGate; // null/empty = everyone\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface DailyRewardDay {\r\n DayNumber?: number; // 1-based, unique per calendar\r\n Rewards?: ResourceGrant;\r\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserDailyCalendarState`\r\n\r\n```ts\r\ninterface UserDailyCalendarState {\r\n CalendarID?: string;\r\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\r\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\r\n}\r\n```\r\n\r\n### Claim eligibility (`ClaimMode`)\r\n\r\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\r\n\r\n- **`CalendarDayUtc`** (default): a new claim is available once\r\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\r\n `\"Daily reward already claimed today for this calendar\"` if the player\r\n already claimed on today's UTC date. Ignores player timezone.\r\n- **`SlidingWindow`**: a new claim is available once\r\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\r\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\r\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\r\n\r\n### Miss detection and `MissBehavior`\r\n\r\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\r\nfirst, before the new day is computed.\r\n\r\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\r\n- **Miss condition** (was the gap too large?):\r\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\r\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\r\n- **On miss**, `CollectedDays` becomes:\r\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\r\n rewards are forfeited, the streak count itself survives).\r\n - `ResetToStart` — `0` (hard streak reset).\r\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\r\n at 0).\r\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\r\n\r\n### Day resolution\r\n\r\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\r\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\r\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\r\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\r\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\r\nlooping — only the _day looked up_ wraps, not the counter).\r\n\r\nDefault-calendar resolution when `calendarID` is omitted: the server uses\r\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\r\nback to the first entry in the dictionary.\r\n\r\n---\r\n\r\n## Idle accruals\r\n\r\n### Config: `IdleAccrualDefinition`\r\n\r\n```ts\r\ninterface IdleAccrualDefinition {\r\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Rate?: IdleRateConfig;\r\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\r\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\r\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\r\n Requirements?: IdleAccrualRequirements;\r\n FirstClaimMode?:\r\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface IdleRateConfig {\r\n BaseRatePerSecond: number; // flat, unconditional\r\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\r\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\r\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\r\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\r\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\r\n}\r\n\r\ninterface PremiumTierMultiplier {\r\n MinPremiumTier?: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n Multiplier?: number;\r\n}\r\n\r\ninterface IdleAccrualRequirements {\r\n MinCharacterLevel: number; // 0 = not checked\r\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\r\n Gate?: SegmentGate; // null/empty = not checked\r\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\r\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\r\n}\r\n```\r\n\r\n### State: `UserIdleAccrualState`\r\n\r\n```ts\r\ninterface UserIdleAccrualState {\r\n AccrualID?: string;\r\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\r\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\r\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\r\n}\r\n```\r\n\r\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\r\n\r\n```\r\nrawRate = BaseRatePerSecond\r\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\r\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\r\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\r\n\r\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\r\n MinPremiumTier <= player's MaxActiveTier (and matching\r\n RequiredPremiumID if set); 1.0 if none match or premium is null\r\n — multipliers never stack.\r\n\r\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\r\n```\r\n\r\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\r\n\r\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\r\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\r\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\r\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\r\nsite yet. The config fields exist and round-trip, but don't promise \"gear\r\nboosts idle income\" in product copy until this is verified live via\r\n`AppliedRatePerSecond` in a real claim response.\r\n\r\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\r\n\r\n```\r\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\r\n = otherwise, resolved by FirstClaimMode:\r\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\r\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\r\n\r\nelapsedSeconds = max(0, now - effectiveStart) in seconds\r\naccruedSeconds = MaxAccumulationSeconds > 0\r\n ? min(elapsedSeconds, MaxAccumulationSeconds)\r\n : elapsedSeconds\r\n```\r\n\r\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\r\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\r\nTry again in {n}s\"`.\r\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\r\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\r\n zero-payout finalize — sets `LastCollectAt = now`, returns\r\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\r\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\r\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\r\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\r\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\r\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\r\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\r\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\r\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\r\n unscaled and are applied afterward by `ResourceService` as usual.\r\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\r\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\r\n\r\n### Requirements gate (checked every claim, not persisted)\r\n\r\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\r\n\r\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\r\nhigher premium tier\"`.\r\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\r\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\r\n'{id}' level {n} is below required {m}\"`.\r\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\r\n `\"Required item '{id}' is not in inventory\"`.\r\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\r\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\r\n'{charID}'\"`.\r\n\r\nFailing a requirement does **not** move `LastCollectAt` — once the\r\nrequirement is met again, the previously-accrued time (up to the cap) is still\r\ncollectible.\r\n\r\n---\r\n\r\n## Comeback rewards\r\n\r\n### Config: `ComebackRewardDefinition`\r\n\r\n```ts\r\ninterface ComebackRewardDefinition {\r\n ComebackID?: string; // key in Comebacks; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Tiers?: ComebackTier[];\r\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\r\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\r\n TrackPresenceOnRead?: boolean; // default true\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ComebackTier {\r\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\r\n Rewards?: ResourceGrant;\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserComebackState`\r\n\r\n```ts\r\ninterface UserComebackState {\r\n ComebackID?: string;\r\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\r\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\r\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\r\n}\r\n```\r\n\r\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\r\n\r\nRuns on **every** claim call for this comeback, and also on\r\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\r\n\r\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\r\n stop — no absence to evaluate yet.\r\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\r\n both set): if `ClaimWindowSeconds > 0` and\r\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\r\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\r\n means it never expires on its own.)\r\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\r\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\r\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\r\n ties broken by taking the higher threshold). If a tier matches AND the\r\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\r\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\r\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\r\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\r\n\r\nThe tier is deliberately locked at the **moment of return**, not at claim\r\ntime — this stops a player from delaying the claim to try to \"grow into\" a\r\nricher tier.\r\n\r\n### Claim (`RewardV2.ClaimComebackReward`)\r\n\r\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\r\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\r\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\r\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\r\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\r\ntimestamp, so a stale pending anchor from a concurrent request can't be\r\ndouble-spent.\r\n\r\n---\r\n\r\n## Claim rewards\r\n\r\n### Config: `ClaimRewardDefinition`\r\n\r\n```ts\r\ninterface ClaimRewardDefinition {\r\n ClaimID?: string; // key in Claims; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\r\n Rewards?: ResourceGrant;\r\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\r\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ClaimLimitOverride {\r\n MinPremiumTier: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\r\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\r\n WindowSeconds?: number | null;\r\n TotalClaimLimit?: number | null;\r\n}\r\n```\r\n\r\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\r\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\r\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\r\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\r\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\r\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\r\n\r\n### State: `UserClaimRewardState`\r\n\r\n```ts\r\ninterface UserClaimRewardState {\r\n ClaimID?: string;\r\n TotalClaims: number; // monotonically increasing; never resets\r\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n}\r\n```\r\n\r\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\r\n\r\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\r\nthe player has an active premium tier, the **one** override with the highest\r\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\r\noverrides never stack. Each of that override's four fields is applied only if\r\nnon-null; a null field falls back to the base `Limits` value, not to \"no\r\nlimit.\"\r\n\r\n### Claim validation order (`RewardV2.PrepareClaimReward`)\r\n\r\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\r\nclaimable by client (server-only)\"`), and `Rewards` is configured.\r\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\r\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\r\n4. Resolve effective limits (base + best override).\r\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\r\n `\"Total claim limit reached ({have}/{limit})\"`.\r\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\r\n `\"Reward is on cooldown. Try again in {n}s\"`.\r\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\r\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\r\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\r\n{window}s)\"`.\r\n8. On success, `now` is appended to the window list, then the list is\r\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\r\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\r\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\r\n first). `TotalClaims` increments by 1 regardless of window/cooldown\r\n settings.\r\n\r\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\r\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\r\nclient `claimReward` call against one is always rejected.\r\n\r\n### Batch claiming (backend-only today)\r\n\r\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\r\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\r\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\r\nindependently (invalid/ineligible ids are filtered out and reported before any\r\ncharge), then applies the merged valid set as a single atomic operation with\r\none combined `Resources` payload attached to the first successful result\r\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\r\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\r\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\r\nso this path is not reachable from the TS client yet.\r\n\r\n---\r\n\r\n## Milestone reward multiplier\r\n\r\n`RewardDefinitions.MilestoneRewardMultiplier` is a\r\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\r\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\r\nfour subsystems; it's a title-wide overlay that other milestone-bearing\r\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\r\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\r\n_last_ overlay in their reward-resolution chain.\r\n\r\n```ts\r\ninterface RewardProgressionMultiplierSpec {\r\n Source?: ProgressionSource; // metric the multiplier is driven by\r\n SourceKey?: string; // disambiguator when Source needs one\r\n Curve?: ScalarCurveSpec; // the curve; base 1 unless Base is set. Empty = no scaling\r\n Anchor?: number; // progress value the curve starts counting from; empty = 0\r\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\r\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\r\n}\r\n```\r\n\r\n`ProgressionSource` values (from `MilestoneModels.ts` /\r\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\r\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\r\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\r\n\r\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\r\n\r\nThe eight fields that used to describe the curve here (`CurveType`, `Tiers`, `TierMode`,\r\n`BaseMultiplier`, `PerUnit`, `MinMultiplier`, `MaxMultiplier`) collapsed into one shared\r\n`ScalarCurveSpec`:\r\n\r\n| Old shape | Now |\r\n| --- | --- |\r\n| `CurveType: \"Tiered\"` + `Tiers` | `Shape: \"Table\"` with `Points: [{ AtStep, Value }]` |\r\n| `TierMode: \"Step\" \\| \"Linear\"` | `Interpolation: \"Step\" \\| \"Linear\"` (also `\"Geometric\"`) |\r\n| `CurveType: \"Linear\"` + `PerUnit` | `Shape: \"PerStepRate\"` (share of the base per unit) |\r\n| `BaseMultiplier` | `Base` (empty = 1, i.e. a multiplier that changes nothing) |\r\n| `MinMultiplier` / `MaxMultiplier` | `MinResult` / `MaxResult` — **empty means NO bound**, and `0` now means a real zero |\r\n\r\n```\r\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\r\n\r\nraw = evaluateCurve(spec.Curve, base = 1.0, step = progress, firstStep = spec.Anchor ?? 0)\r\nfinal = NaN/Infinity -> 1.0\r\n```\r\n\r\n⚠ **The floor \"a reward multiplier never REDUCES a reward\" is no longer a config field.**\r\nIt is a domain rule of the resolver: when the publisher sets no `MinResult`, the result is\r\nfloored at `1.0`. Deliberate reduction is expressed by a curve that DOES set `MinResult`\r\nbelow 1 — so it can only happen on purpose, never by a stray zero.\r\n\r\n⚠ **Before the first table point a curve is the IDENTITY, not the first point's value.**\r\nA player who has not reached the first tier gets no bonus at all.\r\n\r\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\r\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\r\nlive `Multiplier`, the raw `Progress` value read from the player's current\r\nprogression state, and echoes of `Source`/`SourceKey`.\r\n\r\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\r\n\r\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\r\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\r\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\r\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\r\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\r\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\r\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\r\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\r\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\r\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\r\nMatching for items is by `ItemID`; for currencies/event-tokens, by\r\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\r\nuntouched by this step — they're applied afterward, on top of the\r\nalready-scaled `Standard` bundle, by `ResourceService`.\r\n\r\n---\r\n\r\n## Shared plumbing\r\n\r\nThese blocks are reused by all four subsystems (and the rest of the\r\nplatform) — full details live in their own modules; summarized here only as\r\nthey affect Reward.\r\n\r\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\r\n used by `Gate` fields on `DailyCalendarDefinition`,\r\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\r\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\r\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\r\n a failing gate always surfaces as `reason: \"server\"` with a\r\n \"locked behind a higher premium tier\"-style message — there is no\r\n client-visible breakdown of _which_ gate condition failed.\r\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\r\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\r\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\r\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\r\n shared type but ignored by `RewardV2`.\r\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\r\n subsystem's `Rewards` field and every claim response's `data.Resources` use\r\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\r\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\r\n amount by the time it reaches the client.\r\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\r\n one of the four definition types follow the same rule:\r\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\r\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\r\n both may be absent for \"no bound.\"\r\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\r\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\r\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\r\n the three id-taking methods (not `claimDailyReward`'s optional\r\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\r\n trip for the common typo case.\r\n"
|
|
8
|
+
"content": "# Reward data model — reference\n\nFull shape of the config (Definitions) and player state for all four\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\nand the milestone reward multiplier curve. The config side is **strictly\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\nlater still round-trips. The deeper nested shapes shown below as plain\n`interface` blocks in this doc (`DailyCalendarDefinition`,\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\ntypes — most of them are **not individually exported by name** from\n`@idosgames/core`'s public entry point today; don't write `import type {\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\nstyle indexed-access types if you need the standalone name). Per-user state\nobjects beyond the top-level four dictionaries are typed as lenient\npassthrough shapes on the SDK side — the fields documented below are what the\nbackend actually puts on them. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\n- [Tier-reward settings](#tier-reward-settings)\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\n\n---\n\n## Root config: RewardDefinitions\n\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\n`RewardDefinitions.cs`.\n\n```ts\ninterface RewardDefinitions {\n TierRewards?: TierRewardSettings | null;\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\n Claims?: Record<string, ClaimRewardDefinition> | null;\n}\n```\n\nEach of the four dictionaries is an **independent subsystem** — a title can\nuse only some of them; an empty/absent dictionary just means that subsystem is\noff. All four grant rewards through the same `ResourceGrant`, so premium\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\n\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\n\n```ts\ninterface UserRewardState {\n DailyCalendars?: Record<string, UserDailyCalendarState>;\n IdleAccruals?: Record<string, UserIdleAccrualState>;\n Comebacks?: Record<string, UserComebackState>;\n Claims?: Record<string, UserClaimRewardState>;\n}\n```\n\nAn absent entry in any of the four dictionaries means \"player never touched\nthis ID\" — the server treats it as default/zero state, not an error.\n\n---\n\n## Tier-reward settings\n\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\nrewards resolve across _every_ system that has tiers (premium, season, battle\npass, etc.), not just Reward itself. One mode per title.\n\n```ts\ninterface TierRewardSettings {\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\n RewardStackLowerTiers?: boolean; // default: false\n}\n```\n\n- `Additive` — tier rewards are added **on top of** the base reward.\n- `Replace` — tier rewards **fully replace** the base reward.\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\n `false` (default) — only the best matching tier applies.\n\nThis block is read by `ResourceService`, not by Reward's own claim logic\ndirectly — it's here because `RewardDefinitions` is where it's configured.\n\n---\n\n## Daily calendars\n\n### Config: `DailyCalendarDefinition`\n\n```ts\ninterface DailyCalendarDefinition {\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\n MissThresholdMultiplier?: number; // default 2.0\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\n Gate?: SegmentGate; // null/empty = everyone\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface DailyRewardDay {\n DayNumber?: number; // 1-based, unique per calendar\n Rewards?: ResourceGrant;\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserDailyCalendarState`\n\n```ts\ninterface UserDailyCalendarState {\n CalendarID?: string;\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\n}\n```\n\n### Claim eligibility (`ClaimMode`)\n\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\n\n- **`CalendarDayUtc`** (default): a new claim is available once\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\n `\"Daily reward already claimed today for this calendar\"` if the player\n already claimed on today's UTC date. Ignores player timezone.\n- **`SlidingWindow`**: a new claim is available once\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\n\n### Miss detection and `MissBehavior`\n\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\nfirst, before the new day is computed.\n\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\n- **Miss condition** (was the gap too large?):\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\n- **On miss**, `CollectedDays` becomes:\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\n rewards are forfeited, the streak count itself survives).\n - `ResetToStart` — `0` (hard streak reset).\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\n at 0).\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\n\n### Day resolution\n\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\nlooping — only the _day looked up_ wraps, not the counter).\n\nDefault-calendar resolution when `calendarID` is omitted: the server uses\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\nback to the first entry in the dictionary.\n\n---\n\n## Idle accruals\n\n### Config: `IdleAccrualDefinition`\n\n```ts\ninterface IdleAccrualDefinition {\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Rate?: IdleRateConfig;\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\n Requirements?: IdleAccrualRequirements;\n FirstClaimMode?:\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface IdleRateConfig {\n BaseRatePerSecond: number; // flat, unconditional\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\n}\n\ninterface PremiumTierMultiplier {\n MinPremiumTier?: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n Multiplier?: number;\n}\n\ninterface IdleAccrualRequirements {\n MinCharacterLevel: number; // 0 = not checked\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\n Gate?: SegmentGate; // null/empty = not checked\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\n}\n```\n\n### State: `UserIdleAccrualState`\n\n```ts\ninterface UserIdleAccrualState {\n AccrualID?: string;\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\n}\n```\n\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\n\n```\nrawRate = BaseRatePerSecond\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\n\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\n MinPremiumTier <= player's MaxActiveTier (and matching\n RequiredPremiumID if set); 1.0 if none match or premium is null\n — multipliers never stack.\n\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\n```\n\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\n\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\nsite yet. The config fields exist and round-trip, but don't promise \"gear\nboosts idle income\" in product copy until this is verified live via\n`AppliedRatePerSecond` in a real claim response.\n\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\n\n```\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\n = otherwise, resolved by FirstClaimMode:\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\n\nelapsedSeconds = max(0, now - effectiveStart) in seconds\naccruedSeconds = MaxAccumulationSeconds > 0\n ? min(elapsedSeconds, MaxAccumulationSeconds)\n : elapsedSeconds\n```\n\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\nTry again in {n}s\"`.\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\n zero-payout finalize — sets `LastCollectAt = now`, returns\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\n unscaled and are applied afterward by `ResourceService` as usual.\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\n\n### Requirements gate (checked every claim, not persisted)\n\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\n\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\nhigher premium tier\"`.\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\n'{id}' level {n} is below required {m}\"`.\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\n `\"Required item '{id}' is not in inventory\"`.\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\n'{charID}'\"`.\n\nFailing a requirement does **not** move `LastCollectAt` — once the\nrequirement is met again, the previously-accrued time (up to the cap) is still\ncollectible.\n\n---\n\n## Comeback rewards\n\n### Config: `ComebackRewardDefinition`\n\n```ts\ninterface ComebackRewardDefinition {\n ComebackID?: string; // key in Comebacks; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tiers?: ComebackTier[];\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\n TrackPresenceOnRead?: boolean; // default true\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ComebackTier {\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\n Rewards?: ResourceGrant;\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserComebackState`\n\n```ts\ninterface UserComebackState {\n ComebackID?: string;\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\n LastClaimAt: string; // ISO; MinValue = never claimed\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\n}\n```\n\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\n\nRuns on **every** claim call for this comeback, and also on\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\n\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\n stop — no absence to evaluate yet.\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\n both set): if `ClaimWindowSeconds > 0` and\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\n means it never expires on its own.)\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\n ties broken by taking the higher threshold). If a tier matches AND the\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\n\nThe tier is deliberately locked at the **moment of return**, not at claim\ntime — this stops a player from delaying the claim to try to \"grow into\" a\nricher tier.\n\n### Claim (`RewardV2.ClaimComebackReward`)\n\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\ntimestamp, so a stale pending anchor from a concurrent request can't be\ndouble-spent.\n\n---\n\n## Claim rewards\n\n### Config: `ClaimRewardDefinition`\n\n```ts\ninterface ClaimRewardDefinition {\n ClaimID?: string; // key in Claims; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\n Rewards?: ResourceGrant;\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ClaimLimitOverride {\n MinPremiumTier: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\n WindowSeconds?: number | null;\n TotalClaimLimit?: number | null;\n}\n```\n\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\n\n### State: `UserClaimRewardState`\n\n```ts\ninterface UserClaimRewardState {\n ClaimID?: string;\n TotalClaims: number; // monotonically increasing; never resets\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\n LastClaimAt: string; // ISO; MinValue = never claimed\n}\n```\n\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\n\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\nthe player has an active premium tier, the **one** override with the highest\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\noverrides never stack. Each of that override's four fields is applied only if\nnon-null; a null field falls back to the base `Limits` value, not to \"no\nlimit.\"\n\n### Claim validation order (`RewardV2.PrepareClaimReward`)\n\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\nclaimable by client (server-only)\"`), and `Rewards` is configured.\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\n4. Resolve effective limits (base + best override).\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\n `\"Total claim limit reached ({have}/{limit})\"`.\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\n `\"Reward is on cooldown. Try again in {n}s\"`.\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\n{window}s)\"`.\n8. On success, `now` is appended to the window list, then the list is\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\n first). `TotalClaims` increments by 1 regardless of window/cooldown\n settings.\n\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\nclient `claimReward` call against one is always rejected.\n\n### Batch claiming (backend-only today)\n\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\nindependently (invalid/ineligible ids are filtered out and reported before any\ncharge), then applies the merged valid set as a single atomic operation with\none combined `Resources` payload attached to the first successful result\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\nso this path is not reachable from the TS client yet.\n\n---\n\n## Milestone reward multiplier\n\n`RewardDefinitions.MilestoneRewardMultiplier` is a\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\nfour subsystems; it's a title-wide overlay that other milestone-bearing\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\n_last_ overlay in their reward-resolution chain.\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: ProgressionSource; // metric the multiplier is driven by\n SourceKey?: string; // disambiguator when Source needs one\n Curve?: ScalarCurveSpec; // the curve; base 1 unless Base is set. Empty = no scaling\n Anchor?: number; // progress value the curve starts counting from; empty = 0\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\n`ProgressionSource` values (from `MilestoneModels.ts` /\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\n\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\n\nThe eight fields that used to describe the curve here (`CurveType`, `Tiers`, `TierMode`,\n`BaseMultiplier`, `PerUnit`, `MinMultiplier`, `MaxMultiplier`) collapsed into one shared\n`ScalarCurveSpec`:\n\n| Old shape | Now |\n| --------------------------------- | ----------------------------------------------------------------------------------- |\n| `CurveType: \"Tiered\"` + `Tiers` | `Shape: \"Table\"` with `Points: [{ AtStep, Value }]` |\n| `TierMode: \"Step\" \\| \"Linear\"` | `Interpolation: \"Step\" \\| \"Linear\"` (also `\"Geometric\"`) |\n| `CurveType: \"Linear\"` + `PerUnit` | `Shape: \"PerStepRate\"` (share of the base per unit) |\n| `BaseMultiplier` | `Base` (empty = 1, i.e. a multiplier that changes nothing) |\n| `MinMultiplier` / `MaxMultiplier` | `MinResult` / `MaxResult` — **empty means NO bound**, and `0` now means a real zero |\n\n```\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\n\nraw = evaluateCurve(spec.Curve, base = 1.0, step = progress, firstStep = spec.Anchor ?? 0)\nfinal = NaN/Infinity -> 1.0\n```\n\n⚠ **The floor \"a reward multiplier never REDUCES a reward\" is no longer a config field.**\nIt is a domain rule of the resolver: when the publisher sets no `MinResult`, the result is\nfloored at `1.0`. Deliberate reduction is expressed by a curve that DOES set `MinResult`\nbelow 1 — so it can only happen on purpose, never by a stray zero.\n\n⚠ **Before the first table point a curve is the IDENTITY, not the first point's value.**\nA player who has not reached the first tier gets no bonus at all.\n\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\nlive `Multiplier`, the raw `Progress` value read from the player's current\nprogression state, and echoes of `Source`/`SourceKey`.\n\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\n\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\nMatching for items is by `ItemID`; for currencies/event-tokens, by\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\nuntouched by this step — they're applied afterward, on top of the\nalready-scaled `Standard` bundle, by `ResourceService`.\n\n---\n\n## Shared plumbing\n\nThese blocks are reused by all four subsystems (and the rest of the\nplatform) — full details live in their own modules; summarized here only as\nthey affect Reward.\n\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\n used by `Gate` fields on `DailyCalendarDefinition`,\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\n a failing gate always surfaces as `reason: \"server\"` with a\n \"locked behind a higher premium tier\"-style message — there is no\n client-visible breakdown of _which_ gate condition failed.\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\n shared type but ignored by `RewardV2`.\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\n subsystem's `Rewards` field and every claim response's `data.Resources` use\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\n amount by the time it reaches the client.\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\n one of the four definition types follow the same rule:\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\n both may be absent for \"no bound.\"\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\n the three id-taking methods (not `claimDailyReward`'s optional\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\n trip for the common typo case.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|