@idosgames/mcp 0.1.7 → 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/dist/cli.js +5 -5
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +21 -17
- 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 +3 -3
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/collection-system.json +1 -1
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/game-loop-system.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-title-bootstrap.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
- package/registry/skills/user-profile.json +2 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Lootbox data model — reference\r\n\r\nFull shape of the config (`LootboxDefinitions`), the pity/roll formulas\r\ntranscribed from the backend, and the reward-progression multiplier overlay.\r\nAll of these are **strictly typed in the SDK** — `LootboxDefinitions` and every\r\nnested block (`LootboxDefinition`, `LootboxPriceOption`, `LootboxRewardSlot`,\r\n`LootboxRewardRoll`, `LootboxAmountRange`, `LootboxPityRule`) are exported from\r\n`@idosgames/core`, so `getDefinitions()` and\r\n`getSection<LootboxDefinitions>(\"Lootbox\")` give you concrete types, not\r\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\r\nstill round-trips. Field names are PascalCase (straight from the backend\r\nJSON).\r\n\r\n## Contents\r\n\r\n- [Player state](#player-state) — the cached `Lootbox.Pity` map\r\n- [Config: LootboxDefinitions](#config-lootboxdefinitions) — what `getDefinitions()` returns\r\n- [LootboxDefinition](#lootboxdefinition)\r\n- [Price options](#price-options)\r\n- [Reward slots + weighted-roll math](#reward-slots--weighted-roll-math)\r\n- [Pity rules + threshold math](#pity-rules--threshold-math)\r\n- [Catalog pre-filter (SanitizePool)](#catalog-pre-filter-sanitizepool)\r\n- [Reward-progression multiplier (RewardMultiplier)](#reward-progression-multiplier-rewardmultiplier)\r\n- [Cost scaling for count > 1](#cost-scaling-for-count--1)\r\n- [Open response shape](#open-response-shape)\r\n\r\n---\r\n\r\n## Player state\r\n\r\nThere is no `getUserLootboxState()` — the Lootbox module doesn't expose a\r\nstate-fetch method of its own. What the SDK caches locally lives at\r\n`client.data.user.state?.Lootbox`:\r\n\r\n```ts\r\ninterface UserLootboxState {\r\n Pity?: Record<string, UserLootboxPityCounter>; // key = `${LootboxID}:${RuleID}`\r\n}\r\ninterface UserLootboxPityCounter {\r\n OpensSinceLastTrigger: number; // 0..Threshold-1\r\n LastTriggeredAtUtc: string; // ISO timestamp, last time this rule fired\r\n}\r\n```\r\n\r\nThe SDK only **writes** this map from `open()`'s `TriggeredPity` — and only\r\nwhen a trigger actually happened, always resetting `OpensSinceLastTrigger` to\r\n`0` (`LootboxService.ts` → `ctx.data.user.applyLootboxPityTriggers`, then\r\n`UserData.applyLootboxPityTriggers` in `cache/UserData.ts`). It never\r\nincrements the counter locally on a non-triggering open. The **backend**,\r\nhowever, persists the true incremented counter on every single open\r\n(`LootboxHelpers.ComputePityApplication`, see below) — that authoritative\r\n`Lootbox.Pity` map comes down whole as part of `UserState` from\r\n`client.user.getClientState()`. Call that to get an accurate \"N opens until\r\npity\" countdown; don't trust the locally-patched cache for anything beyond\r\n\"did rule X fire, and when.\"\r\n\r\n---\r\n\r\n## Config: LootboxDefinitions\r\n\r\nReturned by `getDefinitions()` as `{ LootboxDefinitions }`; cached via\r\n`client.data.config.getSection<LootboxDefinitions>(\"Lootbox\")`.\r\n\r\n```ts\r\ninterface LootboxDefinitions {\r\n Definitions?: Record<string, LootboxDefinition> | null; // key = LootboxID\r\n}\r\n```\r\n\r\n---\r\n\r\n## LootboxDefinition\r\n\r\nOne box template in the title catalog (`LootboxDefinition.cs`).\r\n\r\n```ts\r\ninterface LootboxDefinition {\r\n LootboxID: string;\r\n AssetPaths?: Record<string, string> | null;\r\n PriceOptions?: Record<string, PriceOption> | null; // key = OptionID\r\n RewardSlots?: LootboxRewardSlot[] | null;\r\n PityRules?: LootboxPityRule[] | null;\r\n RewardMultiplier?: RewardProgressionMultiplierSpec | null; // see below; not a separately exported type\r\n}\r\n```\r\n\r\n`PriceOptions` is the platform-wide price shape: the dictionary key **is** the\r\n`OptionID`, and you pass that string as `selectedOptionID` to `open()`. Omit it\r\nand the server takes the first option available on the caller's platform, so a\r\nbox with a single price needs no client change.\r\n\r\n---\r\n\r\n## Price options\r\n\r\n```ts\r\ninterface PriceOption {\r\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\r\n Name?: string; // display name / localization key\r\n Cost?: ResourceConsume; // consume-only: what this option charges\r\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n`Cost` is a plain `ResourceConsume` (`Standard` + optional `PremiumDiscounts`).\r\nThe backend requires at least one of: a non-empty `Standard.Entries`, a non-empty\r\n`Standard.EventTokens`, or a non-empty `PremiumDiscounts` list — an option with\r\nall three empty rejects with `\"Price option 'X' is empty.\"` (a box gated entirely\r\nbehind a 100%-off premium discount is valid: F2P players simply can't afford it\r\nand get a normal insufficient-funds rejection).\r\n\r\nA `Cost` entry of type `Purchase` means the option is paid **in a store**: buy the\r\nproduct first and pass the receipt as `open()`'s `payment` argument. Render options\r\nwith `client.checkout.availableOptions(def.PriceOptions)` — see the\r\n`checkout-system` skill.\r\n\r\n---\r\n\r\n## Reward slots + weighted-roll math\r\n\r\n```ts\r\ninterface LootboxRewardSlot {\r\n SlotID?: string; // analytics/UI/debug id, not roll logic\r\n MinRolls?: number;\r\n MaxRolls?: number;\r\n Pool?: LootboxRewardRoll[];\r\n}\r\ninterface LootboxRewardRoll {\r\n Reward?: ResourceGrant; // grant-only: Standard.Entries / Standard.EventTokens / PremiumTiers\r\n Weight?: number;\r\n AmountRange?: LootboxAmountRange; // { Min: number; Max: number }\r\n}\r\n```\r\n\r\nPer box opened, **every slot rolls independently** (`RewardSlotHelpers.RollSlots`\r\nin `Services/RewardSlotHelpers.cs`):\r\n\r\n1. **Roll count** for the slot is uniform-random in `[MinRolls, MaxRolls]`\r\n inclusive (`min = max(0, MinRolls)`, `max = max(min, MaxRolls)`). Set\r\n `MinRolls = MaxRolls = 1` for a guaranteed single roll; `MinRolls = 0` to\r\n make the whole slot optional.\r\n2. Each individual roll picks **one** entry from `Pool` by weight: sum all\r\n `Weight` values (entries with `Weight <= 0` or no `Reward` are skipped),\r\n draw a uniform random integer in `[0, totalWeight)` via the platform's\r\n `SecureRandom`, and walk the cumulative weights to find the hit — a\r\n standard weighted pick, not a percentage table you need to normalize\r\n yourself.\r\n3. If the picked entry has `AmountRange`, the final `Amount` is a uniform\r\n random integer in `[Min, Max]` (inclusive; `max` is clamped to be `>= min`)\r\n and **replaces** `Amount` on every entry/token inside that roll's `Reward`\r\n — not just one. This is why the backend comment recommends one resource per\r\n `AmountRange` entry: a `Reward` with two different currencies sharing one\r\n `AmountRange` would apply the _same_ rolled number to both.\r\n4. All rolls across all slots (plus any pity rolls, see below) are merged into\r\n one `ResourceOperation` by summing same-key entries (same\r\n `Type`+`CurrencyID`/`ItemID`+`CatalogID`) and same-address event tokens.\r\n\r\nThere is no \"duplicate protection\" or per-roll independence guarantee beyond\r\nwhat `Pool` weights encode — two rolls in the same box can land on the same\r\npool entry.\r\n\r\n---\r\n\r\n## Pity rules + threshold math\r\n\r\n```ts\r\ninterface LootboxPityRule {\r\n RuleID?: string; // stable — renaming resets every player's counter\r\n Threshold?: number; // must be >= 1\r\n Pool?: LootboxRewardRoll[]; // same weighted-roll shape as a reward slot's Pool\r\n}\r\n```\r\n\r\nThis is a **plain running counter of opens**, not \"opens since last rare drop\"\r\n— it counts every open of this `LootboxID` regardless of what was rolled, and\r\nis completely independent of `RewardSlots`. Pity rewards are granted **in\r\naddition to** the normal slot rolls, not instead of them.\r\n\r\nPer-rule math for one `open(lootboxID, count, ...)` call\r\n(`LootboxHelpers.ComputePityApplication` in `Services/LootboxHelpers.cs`):\r\n\r\n```\r\nkey = `${lootboxID}:${RuleID}`\r\ncurrentCounter = cached counter for key, or 0 if absent\r\ntotalSteps = currentCounter + count\r\ntriggers = floor(totalSteps / Threshold) // how many times this rule fires\r\nnewCounter = totalSteps % Threshold // counter value after this open\r\n```\r\n\r\n- `triggers` can be **more than 1** in a single call when `count` is large\r\n relative to `Threshold` (e.g. opening 25 boxes against `Threshold = 10`\r\n starting from counter 8 triggers twice: at step 10 and step 20, ending at\r\n counter 3).\r\n- Each trigger does **one independent weighted roll** over the rule's own\r\n `Pool` (same algorithm as a reward slot roll, including `AmountRange`).\r\n- Each trigger's `BoxIndex` (0-based, into `Results`/the batch) is computed as\r\n `(Threshold - 1 - currentCounter) + i * Threshold` for the `i`-th trigger\r\n (0-based) of that rule within this call — i.e. the exact box in the batch\r\n that pushed the counter over the threshold. Always in `[0, count - 1]`.\r\n- Multiple pity rules on the same box are **fully independent**: each tracks\r\n its own counter under its own `key` and can trigger on different boxes\r\n within the same batch (e.g. a `Threshold = 10` \"bonus sticker\" rule and a\r\n `Threshold = 90` \"guaranteed legendary\" rule).\r\n- A rule with `Threshold <= 0`, no `RuleID`, or an empty `Pool` is skipped\r\n entirely (never triggers, never patches a counter) — treat it as\r\n misconfigured rather than \"always trigger\" or \"never trigger by design.\"\r\n\r\nThe counter is persisted via a Mongo patch in the **same atomic transaction**\r\nas the resource grant/consume (`extraPatches` passed into\r\n`ResourceService.ApplyResourceOperationAtomicAsync`) — a failed/insufficient-funds\r\nopen never advances the pity counter, and a successful open's counter update\r\ncan never be \"lost\" relative to the reward it unlocked.\r\n\r\n---\r\n\r\n## Catalog pre-filter (SanitizePool)\r\n\r\nBefore rolling, both `RollSlots` and pity's `RollWithWeight` **pre-filter**\r\neach `Pool` through the title's active item catalogs\r\n(`RewardSlotHelpers.SanitizePool`, shared with the Collection module's bonus\r\nslots):\r\n\r\n- Any `Reward.Standard.Entries` item entry that doesn't resolve in\r\n `ItemDefinitions.Catalogs` (via the same `ItemCatalogResolver` used\r\n elsewhere, strict match with fallback for a moved-catalog item) is stripped\r\n from that pool entry's grant.\r\n- If a pool entry's `Reward` has **no resource left** after stripping (no\r\n surviving item, currency, or event token in `Standard`, and none in any\r\n `PremiumTiers` bundle), the whole entry is dropped from the pool — its\r\n `Weight` is simply excluded from `totalWeight`, so it doesn't dilute the\r\n remaining valid entries and doesn't produce an empty-reward roll.\r\n- Currency and event-token entries are **never** stripped — only `Item`-type\r\n entries are checked against the catalog.\r\n- If no `ItemDefinitions`/`Catalogs` are supplied at all, the pool is used\r\n as-is (backward-compatible no-op).\r\n\r\nNet effect for you as a consumer: a stale/removed item reference in a\r\nlootbox's config can never crash or nullify an open — worst case, that one\r\nweighted slice of the pool silently stops being reachable until the config is\r\nfixed. You don't need to defend against \"got an empty reward\" client-side.\r\n\r\n---\r\n\r\n## Reward-progression multiplier (RewardMultiplier)\r\n\r\n```ts\r\ninterface RewardProgressionMultiplierSpec {\r\n Source?: string; // \"BoardStageLevel\" | \"BoardRank\" | \"BoardCyclesCompleted\"\r\n // | \"CharacterLevel\" | \"SeasonTier\" | \"EventTokenTotalEarned\"\r\n // | \"VirtualCurrencyBalance\" | \"PlayerLevel\"\r\n SourceKey?: string; // e.g. which currency/event-token id, when Source needs a key\r\n Curve?: ScalarCurveSpec; // the shared platform curve; empty = no scaling\r\n Anchor?: number; // progress value the curve starts counting from; empty = 0\r\n IncludeRewards?: ResourceBundle; // restrict which reward entries the multiplier scales\r\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\r\n}\r\n```\r\n\r\nThis is the same platform-wide progression-multiplier overlay used by other\r\nmodules (Reward's `MilestoneRewardMultiplier`, Season tier rewards, etc.) —\r\n**not a separate type exported from `@idosgames/core`'s public index**; it\r\nonly appears structurally as the `RewardMultiplier` field's type inside\r\n`LootboxDefinition`. Read it off the resolved definitions object, don't try to\r\n`import type { RewardProgressionMultiplierSpec }` directly.\r\n\r\nUnlike Reward's version, Lootbox has **no getter** for the resolved\r\nmultiplier — there's nothing like `getMilestoneRewardMultiplier()` here. The\r\nbackend evaluates it internally on every `open()` and applies it silently:\r\n\r\n- `null`/absent `RewardMultiplier` → multiplier is always `1.0`, i.e. no-op.\r\n- Otherwise the backend reads the player's current progress for\r\n `Source`/`SourceKey`, evaluates `Curve` from a base of `1.0` at\r\n `step = progress`, `firstStep = Anchor` (tiered breakpoints are\r\n `Shape: \"Table\"`, a linear ramp is `Shape: \"PerStepRate\"`), clamps it to\r\n `MinResult`/`MaxResult` — **an empty bound means NO bound**, unlike the old\r\n `MaxMultiplier <= 0` convention — and scales matching reward entries' `Amount` with\r\n ceiling-rounding (shared `ModifierService` — same rounding rule used\r\n platform-wide, not reimplemented per module).\r\n- The multiplier applies to **both** normal `RewardSlots` rolls and pity\r\n rewards, scaled **per box** before merging (so `Results[i]` for each box in\r\n a batch already reflects the multiplier). It never touches the **cost**\r\n (`PriceOptions`) — only what's granted.\r\n- `IncludeRewards`/`ExcludeRewards` let the title scope the multiplier to\r\n specific currencies/items/event-tokens instead of the whole grant; an empty\r\n `IncludeRewards` means \"everything,\" and `ExcludeRewards` wins on conflict.\r\n\r\nBecause this all happens server-side with no exposed getter, there is no\r\nclient-side way to preview the exact multiplier before opening — if you want\r\nto show \"your rewards are boosted,\" drive that off whatever domain state\r\nbacks `Source` (e.g. the player's board stage, character level) rather than\r\ntrying to recompute the curve.\r\n\r\n---\r\n\r\n## Cost scaling for count > 1\r\n\r\n`open(lootboxID, count, selectedOptionID, payment?)` charges `count` times the\r\nselected option's `Cost.Standard`, computed by grouping+summing\r\n(`BuildScaledCost` in `Lootbox.cs`): every `VirtualCurrency` entry keyed by\r\n`CurrencyID` and every `Item` entry keyed by `(CatalogID, ItemID)` has its\r\n`Amount` multiplied by `count` and duplicate keys merged before charging —\r\n`PremiumDiscounts` are not pre-scaled here; they're applied automatically\r\ninside `ResourceService`'s premium-discount filtering on the final merged\r\ncost. `count` is clamped server-side to `[1, 100]` regardless of what you\r\nsend.\r\n\r\n---\r\n\r\n## Open response shape\r\n\r\n```ts\r\ninterface LootboxOpenResponse {\r\n ServerTimeUtc: string;\r\n LootboxID: string;\r\n OpenedCount?: number; // == the clamped count actually processed\r\n SelectedOptionID?: number;\r\n Resources?: ResourceOperation; // aggregated grant (all boxes + pity) and the total consume (cost)\r\n Results?: ResourceOperation[]; // one entry per box opened, in order; pity rewards folded into the box that triggered them\r\n TriggeredPity?: LootboxPityTriggerResponse[]; // null if no rule fired this call\r\n}\r\ninterface LootboxPityTriggerResponse {\r\n RuleID: string;\r\n BoxIndex?: number; // 0-based index into Results for the box that crossed the threshold\r\n}\r\n```\r\n\r\n`Results[i].Grant` is filtered per-box for the player's active premium tier\r\nbefore being returned (`ResourceService.FilterByPremium`), so its totals sum\r\nto `Resources.Grant` — both reflect what the player is actually entitled to,\r\nnot the raw unfiltered config. `Results[i]`'s event tokens are plain\r\n`EventTokenOperation` (no `Requested`/`Applied`/`NewBalance` — those only\r\nexist on the aggregated `Resources`), so read balances/streaks from\r\n`Resources`, not from `Results`.\r\n"
|
|
8
|
+
"content": "# Lootbox data model — reference\n\nFull shape of the config (`LootboxDefinitions`), the pity/roll formulas\ntranscribed from the backend, and the reward-progression multiplier overlay.\nAll of these are **strictly typed in the SDK** — `LootboxDefinitions` and every\nnested block (`LootboxDefinition`, `LootboxPriceOption`, `LootboxRewardSlot`,\n`LootboxRewardRoll`, `LootboxAmountRange`, `LootboxPityRule`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<LootboxDefinitions>(\"Lootbox\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — the cached `Lootbox.Pity` map\n- [Config: LootboxDefinitions](#config-lootboxdefinitions) — what `getDefinitions()` returns\n- [LootboxDefinition](#lootboxdefinition)\n- [Price options](#price-options)\n- [Reward slots + weighted-roll math](#reward-slots--weighted-roll-math)\n- [Pity rules + threshold math](#pity-rules--threshold-math)\n- [Catalog pre-filter (SanitizePool)](#catalog-pre-filter-sanitizepool)\n- [Reward-progression multiplier (RewardMultiplier)](#reward-progression-multiplier-rewardmultiplier)\n- [Cost scaling for count > 1](#cost-scaling-for-count--1)\n- [Open response shape](#open-response-shape)\n\n---\n\n## Player state\n\nThere is no `getUserLootboxState()` — the Lootbox module doesn't expose a\nstate-fetch method of its own. What the SDK caches locally lives at\n`client.data.user.state?.Lootbox`:\n\n```ts\ninterface UserLootboxState {\n Pity?: Record<string, UserLootboxPityCounter>; // key = `${LootboxID}:${RuleID}`\n}\ninterface UserLootboxPityCounter {\n OpensSinceLastTrigger: number; // 0..Threshold-1\n LastTriggeredAtUtc: string; // ISO timestamp, last time this rule fired\n}\n```\n\nThe SDK only **writes** this map from `open()`'s `TriggeredPity` — and only\nwhen a trigger actually happened, always resetting `OpensSinceLastTrigger` to\n`0` (`LootboxService.ts` → `ctx.data.user.applyLootboxPityTriggers`, then\n`UserData.applyLootboxPityTriggers` in `cache/UserData.ts`). It never\nincrements the counter locally on a non-triggering open. The **backend**,\nhowever, persists the true incremented counter on every single open\n(`LootboxHelpers.ComputePityApplication`, see below) — that authoritative\n`Lootbox.Pity` map comes down whole as part of `UserState` from\n`client.user.getClientState()`. Call that to get an accurate \"N opens until\npity\" countdown; don't trust the locally-patched cache for anything beyond\n\"did rule X fire, and when.\"\n\n---\n\n## Config: LootboxDefinitions\n\nReturned by `getDefinitions()` as `{ LootboxDefinitions }`; cached via\n`client.data.config.getSection<LootboxDefinitions>(\"Lootbox\")`.\n\n```ts\ninterface LootboxDefinitions {\n Definitions?: Record<string, LootboxDefinition> | null; // key = LootboxID\n}\n```\n\n---\n\n## LootboxDefinition\n\nOne box template in the title catalog (`LootboxDefinition.cs`).\n\n```ts\ninterface LootboxDefinition {\n LootboxID: string;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<string, PriceOption> | null; // key = OptionID\n RewardSlots?: LootboxRewardSlot[] | null;\n PityRules?: LootboxPityRule[] | null;\n RewardMultiplier?: RewardProgressionMultiplierSpec | null; // see below; not a separately exported type\n}\n```\n\n`PriceOptions` is the platform-wide price shape: the dictionary key **is** the\n`OptionID`, and you pass that string as `selectedOptionID` to `open()`. Omit it\nand the server takes the first option available on the caller's platform, so a\nbox with a single price needs no client change.\n\n---\n\n## Price options\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string; // display name / localization key\n Cost?: ResourceConsume; // consume-only: what this option charges\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n`Cost` is a plain `ResourceConsume` (`Standard` + optional `PremiumDiscounts`).\nThe backend requires at least one of: a non-empty `Standard.Entries`, a non-empty\n`Standard.EventTokens`, or a non-empty `PremiumDiscounts` list — an option with\nall three empty rejects with `\"Price option 'X' is empty.\"` (a box gated entirely\nbehind a 100%-off premium discount is valid: F2P players simply can't afford it\nand get a normal insufficient-funds rejection).\n\nA `Cost` entry of type `Purchase` means the option is paid **in a store**: buy the\nproduct first and pass the receipt as `open()`'s `payment` argument. Render options\nwith `client.checkout.availableOptions(def.PriceOptions)` — see the\n`checkout-system` skill.\n\n---\n\n## Reward slots + weighted-roll math\n\n```ts\ninterface LootboxRewardSlot {\n SlotID?: string; // analytics/UI/debug id, not roll logic\n MinRolls?: number;\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only: Standard.Entries / Standard.EventTokens / PremiumTiers\n Weight?: number;\n AmountRange?: LootboxAmountRange; // { Min: number; Max: number }\n}\n```\n\nPer box opened, **every slot rolls independently** (`RewardSlotHelpers.RollSlots`\nin `Services/RewardSlotHelpers.cs`):\n\n1. **Roll count** for the slot is uniform-random in `[MinRolls, MaxRolls]`\n inclusive (`min = max(0, MinRolls)`, `max = max(min, MaxRolls)`). Set\n `MinRolls = MaxRolls = 1` for a guaranteed single roll; `MinRolls = 0` to\n make the whole slot optional.\n2. Each individual roll picks **one** entry from `Pool` by weight: sum all\n `Weight` values (entries with `Weight <= 0` or no `Reward` are skipped),\n draw a uniform random integer in `[0, totalWeight)` via the platform's\n `SecureRandom`, and walk the cumulative weights to find the hit — a\n standard weighted pick, not a percentage table you need to normalize\n yourself.\n3. If the picked entry has `AmountRange`, the final `Amount` is a uniform\n random integer in `[Min, Max]` (inclusive; `max` is clamped to be `>= min`)\n and **replaces** `Amount` on every entry/token inside that roll's `Reward`\n — not just one. This is why the backend comment recommends one resource per\n `AmountRange` entry: a `Reward` with two different currencies sharing one\n `AmountRange` would apply the _same_ rolled number to both.\n4. All rolls across all slots (plus any pity rolls, see below) are merged into\n one `ResourceOperation` by summing same-key entries (same\n `Type`+`CurrencyID`/`ItemID`+`CatalogID`) and same-address event tokens.\n\nThere is no \"duplicate protection\" or per-roll independence guarantee beyond\nwhat `Pool` weights encode — two rolls in the same box can land on the same\npool entry.\n\n---\n\n## Pity rules + threshold math\n\n```ts\ninterface LootboxPityRule {\n RuleID?: string; // stable — renaming resets every player's counter\n Threshold?: number; // must be >= 1\n Pool?: LootboxRewardRoll[]; // same weighted-roll shape as a reward slot's Pool\n}\n```\n\nThis is a **plain running counter of opens**, not \"opens since last rare drop\"\n— it counts every open of this `LootboxID` regardless of what was rolled, and\nis completely independent of `RewardSlots`. Pity rewards are granted **in\naddition to** the normal slot rolls, not instead of them.\n\nPer-rule math for one `open(lootboxID, count, ...)` call\n(`LootboxHelpers.ComputePityApplication` in `Services/LootboxHelpers.cs`):\n\n```\nkey = `${lootboxID}:${RuleID}`\ncurrentCounter = cached counter for key, or 0 if absent\ntotalSteps = currentCounter + count\ntriggers = floor(totalSteps / Threshold) // how many times this rule fires\nnewCounter = totalSteps % Threshold // counter value after this open\n```\n\n- `triggers` can be **more than 1** in a single call when `count` is large\n relative to `Threshold` (e.g. opening 25 boxes against `Threshold = 10`\n starting from counter 8 triggers twice: at step 10 and step 20, ending at\n counter 3).\n- Each trigger does **one independent weighted roll** over the rule's own\n `Pool` (same algorithm as a reward slot roll, including `AmountRange`).\n- Each trigger's `BoxIndex` (0-based, into `Results`/the batch) is computed as\n `(Threshold - 1 - currentCounter) + i * Threshold` for the `i`-th trigger\n (0-based) of that rule within this call — i.e. the exact box in the batch\n that pushed the counter over the threshold. Always in `[0, count - 1]`.\n- Multiple pity rules on the same box are **fully independent**: each tracks\n its own counter under its own `key` and can trigger on different boxes\n within the same batch (e.g. a `Threshold = 10` \"bonus sticker\" rule and a\n `Threshold = 90` \"guaranteed legendary\" rule).\n- A rule with `Threshold <= 0`, no `RuleID`, or an empty `Pool` is skipped\n entirely (never triggers, never patches a counter) — treat it as\n misconfigured rather than \"always trigger\" or \"never trigger by design.\"\n\nThe counter is persisted via a Mongo patch in the **same atomic transaction**\nas the resource grant/consume (`extraPatches` passed into\n`ResourceService.ApplyResourceOperationAtomicAsync`) — a failed/insufficient-funds\nopen never advances the pity counter, and a successful open's counter update\ncan never be \"lost\" relative to the reward it unlocked.\n\n---\n\n## Catalog pre-filter (SanitizePool)\n\nBefore rolling, both `RollSlots` and pity's `RollWithWeight` **pre-filter**\neach `Pool` through the title's active item catalogs\n(`RewardSlotHelpers.SanitizePool`, shared with the Collection module's bonus\nslots):\n\n- Any `Reward.Standard.Entries` item entry that doesn't resolve in\n `ItemDefinitions.Catalogs` (via the same `ItemCatalogResolver` used\n elsewhere, strict match with fallback for a moved-catalog item) is stripped\n from that pool entry's grant.\n- If a pool entry's `Reward` has **no resource left** after stripping (no\n surviving item, currency, or event token in `Standard`, and none in any\n `PremiumTiers` bundle), the whole entry is dropped from the pool — its\n `Weight` is simply excluded from `totalWeight`, so it doesn't dilute the\n remaining valid entries and doesn't produce an empty-reward roll.\n- Currency and event-token entries are **never** stripped — only `Item`-type\n entries are checked against the catalog.\n- If no `ItemDefinitions`/`Catalogs` are supplied at all, the pool is used\n as-is (backward-compatible no-op).\n\nNet effect for you as a consumer: a stale/removed item reference in a\nlootbox's config can never crash or nullify an open — worst case, that one\nweighted slice of the pool silently stops being reachable until the config is\nfixed. You don't need to defend against \"got an empty reward\" client-side.\n\n---\n\n## Reward-progression multiplier (RewardMultiplier)\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: string; // \"BoardStageLevel\" | \"BoardRank\" | \"BoardCyclesCompleted\"\n // | \"CharacterLevel\" | \"SeasonTier\" | \"EventTokenTotalEarned\"\n // | \"VirtualCurrencyBalance\" | \"PlayerLevel\"\n SourceKey?: string; // e.g. which currency/event-token id, when Source needs a key\n Curve?: ScalarCurveSpec; // the shared platform curve; empty = no scaling\n Anchor?: number; // progress value the curve starts counting from; empty = 0\n IncludeRewards?: ResourceBundle; // restrict which reward entries the multiplier scales\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\nThis is the same platform-wide progression-multiplier overlay used by other\nmodules (Reward's `MilestoneRewardMultiplier`, Season tier rewards, etc.) —\n**not a separate type exported from `@idosgames/core`'s public index**; it\nonly appears structurally as the `RewardMultiplier` field's type inside\n`LootboxDefinition`. Read it off the resolved definitions object, don't try to\n`import type { RewardProgressionMultiplierSpec }` directly.\n\nUnlike Reward's version, Lootbox has **no getter** for the resolved\nmultiplier — there's nothing like `getMilestoneRewardMultiplier()` here. The\nbackend evaluates it internally on every `open()` and applies it silently:\n\n- `null`/absent `RewardMultiplier` → multiplier is always `1.0`, i.e. no-op.\n- Otherwise the backend reads the player's current progress for\n `Source`/`SourceKey`, evaluates `Curve` from a base of `1.0` at\n `step = progress`, `firstStep = Anchor` (tiered breakpoints are\n `Shape: \"Table\"`, a linear ramp is `Shape: \"PerStepRate\"`), clamps it to\n `MinResult`/`MaxResult` — **an empty bound means NO bound**, unlike the old\n `MaxMultiplier <= 0` convention — and scales matching reward entries' `Amount` with\n ceiling-rounding (shared `ModifierService` — same rounding rule used\n platform-wide, not reimplemented per module).\n- The multiplier applies to **both** normal `RewardSlots` rolls and pity\n rewards, scaled **per box** before merging (so `Results[i]` for each box in\n a batch already reflects the multiplier). It never touches the **cost**\n (`PriceOptions`) — only what's granted.\n- `IncludeRewards`/`ExcludeRewards` let the title scope the multiplier to\n specific currencies/items/event-tokens instead of the whole grant; an empty\n `IncludeRewards` means \"everything,\" and `ExcludeRewards` wins on conflict.\n\nBecause this all happens server-side with no exposed getter, there is no\nclient-side way to preview the exact multiplier before opening — if you want\nto show \"your rewards are boosted,\" drive that off whatever domain state\nbacks `Source` (e.g. the player's board stage, character level) rather than\ntrying to recompute the curve.\n\n---\n\n## Cost scaling for count > 1\n\n`open(lootboxID, count, selectedOptionID, payment?)` charges `count` times the\nselected option's `Cost.Standard`, computed by grouping+summing\n(`BuildScaledCost` in `Lootbox.cs`): every `VirtualCurrency` entry keyed by\n`CurrencyID` and every `Item` entry keyed by `(CatalogID, ItemID)` has its\n`Amount` multiplied by `count` and duplicate keys merged before charging —\n`PremiumDiscounts` are not pre-scaled here; they're applied automatically\ninside `ResourceService`'s premium-discount filtering on the final merged\ncost. `count` is clamped server-side to `[1, 100]` regardless of what you\nsend.\n\n---\n\n## Open response shape\n\n```ts\ninterface LootboxOpenResponse {\n ServerTimeUtc: string;\n LootboxID: string;\n OpenedCount?: number; // == the clamped count actually processed\n SelectedOptionID?: number;\n Resources?: ResourceOperation; // aggregated grant (all boxes + pity) and the total consume (cost)\n Results?: ResourceOperation[]; // one entry per box opened, in order; pity rewards folded into the box that triggered them\n TriggeredPity?: LootboxPityTriggerResponse[]; // null if no rule fired this call\n}\ninterface LootboxPityTriggerResponse {\n RuleID: string;\n BoxIndex?: number; // 0-based index into Results for the box that crossed the threshold\n}\n```\n\n`Results[i].Grant` is filtered per-box for the player's active premium tier\nbefore being returned (`ResourceService.FilterByPremium`), so its totals sum\nto `Resources.Grant` — both reflect what the player is actually entitled to,\nnot the raw unfiltered config. `Results[i]`'s event tokens are plain\n`EventTokenOperation` (no `Requested`/`Applied`/`NewBalance` — those only\nexist on the aggregated `Resources`), so read balances/streaks from\n`Resources`, not from `Results`.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Match data model — reference\r\n\r\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\r\nrequest/response types. All of these are **strictly typed in the SDK** —\r\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\r\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\r\n`@idosgames/core`, so `getDefinitions()` and\r\n`getSection<MatchDefinitions>(\"Match\")` 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\nTerminology: the backend consistently calls the cost to participate **Entry**\r\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\r\nin the Match model. Use that vocabulary in any UI copy you generate.\r\n\r\n## Contents\r\n\r\n- [Player state](#player-state) — `UserMatchState`\r\n- [Match (offer)](#match-offer) — `PvPMatch`\r\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\r\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\r\n- [InstantBattleRule](#instantbattlerule)\r\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\r\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\r\n- [Net reward / burn formula](#net-reward--burn-formula)\r\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\r\n- [Request shape](#request-shape)\r\n\r\n---\r\n\r\n## Player state\r\n\r\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\r\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\r\nhydrates it at login.\r\n\r\n```ts\r\ninterface UserMatchState {\r\n PvPBattleStrategy?: BattleStepConfig[];\r\n CreationLimits?: UserMatchCreationLimitState | null;\r\n}\r\n\r\ninterface UserMatchCreationLimitState {\r\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\r\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\r\n DailyResetUtc?: string; // next UTC midnight reset\r\n}\r\n\r\ninterface BattleStepConfig {\r\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\r\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\r\n}\r\n```\r\n\r\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\r\nserver-side (`Match.CreationLimits` on the player document, written by\r\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\r\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\r\nfor a client to eventually show \"next match available in…\" UI, but nothing in\r\n`MatchService` currently reads it back into this cache slot — treat it as\r\ninformational/future until a response actually populates it for you.\r\n\r\n---\r\n\r\n## Match (offer)\r\n\r\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\r\nand `UpdateMatchResponse`.\r\n\r\n```ts\r\ninterface PvPMatch {\r\n MatchID: string;\r\n TitleID?: string;\r\n RuleID?: string;\r\n CreatedAt?: string;\r\n CreatorID?: string;\r\n CreatorCharacterID?: string;\r\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\r\n TargetUserID?: string; // set = private/targeted challenge; absent = public\r\n Entry?: ResourceBundle; // the creator's entry cost\r\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\r\n RefundCreationCostOnCancel?: boolean;\r\n JoinedByUserID?: string;\r\n JoinedByCharacterID?: string;\r\n JoinedAt?: string;\r\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\r\n WinnerUserID?: string; // absent/null on a draw\r\n CompletedAt?: string;\r\n IsRewardDistributed?: boolean;\r\n RewardDistributedAt?: string;\r\n}\r\n```\r\n\r\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\r\nmatch) are tracked separately — cancelling refunds the entry cost always, and\r\nthe creation fee only when `RefundCreationCostOnCancel` is true.\r\n\r\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\r\nresolves the battle synchronously in the same call, so a match goes directly\r\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\r\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\r\nenum for forward-compat / other match modes, not for instant-battle.\r\n\r\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\r\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\r\nmatch's strategy from `getMyMatches` or after you've fetched the match some\r\nother way; it isn't needed to join, since your own strategy is what you send\r\nto `instantBattle`.\r\n\r\n---\r\n\r\n## Battle result\r\n\r\nReturned inside `InstantBattleResponse.Battle`.\r\n\r\n```ts\r\ninterface BattleResult {\r\n WinnerUserID?: string; // absent on a draw\r\n LoserUserID?: string; // absent on a draw\r\n Entry?: ResourceBundle; // one side's entry cost that was in play\r\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\r\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\r\n IsDraw?: boolean;\r\n P1BattleProfile?: PlayerBattleProfile; // the match creator\r\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\r\n}\r\n\r\ninterface BattleLogEntry {\r\n RoundIndex?: number; // 1-based\r\n AttackerID?: string;\r\n DefenderID?: string;\r\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\r\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\r\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\r\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\r\n DefenderHpRemaining?: number; // floored at 0\r\n}\r\n\r\ninterface PlayerBattleProfile {\r\n UserID?: string;\r\n SelectedCharacterID?: string;\r\n SelectedCharacter?: CharacterModel; // see character-system skill\r\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\r\n Stats?: FighterStats; // final computed combat stats used for this fight\r\n}\r\n\r\ninterface FighterStats {\r\n MaxHp?: number; // starting HP, for a results-screen HP bar\r\n CurrentHp?: number; // HP at the end of the fight\r\n Damage?: number;\r\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\r\n CritChance?: number; // 0..MaxCritChance\r\n CritMultiplier?: number;\r\n Armor?: number; // flat damage reduction\r\n DodgeChance?: number; // 0..MaxDodgeChance\r\n}\r\n```\r\n\r\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\r\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\r\nhit drops the defender to 0 HP, the defender does not get to act that round.\r\n`AttackZone`/`DefenseZone` per log entry come from each side's\r\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\r\nstrategy shorter than the battle simply repeats from the top.\r\n\r\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\r\n\r\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\r\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\r\n3. `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\r\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\r\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\r\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\r\n\r\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\r\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\r\n\r\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\r\nlevel-scaling snapshot the engine used internally — the backend\r\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\r\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\r\nthat depends on it being populated.\r\n\r\n`FighterStats` is the resolved combat stats each fighter fought with — read\r\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\r\none fight, not a live/cached character stat.\r\n\r\n---\r\n\r\n## Config: MatchDefinitions\r\n\r\nReturned by `getDefinitions()`; cached via\r\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\r\n\r\n```ts\r\ninterface MatchDefinitions {\r\n InstantBattle?: InstantBattleDefinitions;\r\n}\r\n\r\ninterface InstantBattleDefinitions {\r\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\r\n Defaults?: InstantBattleSettings; // title-wide combat fallback\r\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\r\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\r\n}\r\n```\r\n\r\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\r\nthere's no other battle mode in the model today. If the title hasn't\r\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\r\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\r\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\r\n— so there is always at least one valid `RuleID` to pass.\r\n\r\nResolution order for every block is **rule's own → title `Defaults` (or\r\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\r\ninline-over-preset pattern the character module uses. `StatMapping` resolves\r\nper-field (each role can come from a different layer); `Combat`, `Entry`,\r\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\r\ntitle's `EntryDefaults`, even for fields it left unset).\r\n\r\n---\r\n\r\n## InstantBattleRule\r\n\r\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\r\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\r\n\r\n```ts\r\ninterface InstantBattleRule {\r\n RuleID?: string;\r\n DisplayName?: string;\r\n Description?: string;\r\n Economy?: MatchEconomySettings;\r\n Entry?: MatchEntrySettings;\r\n Creation?: MatchCreationSettings;\r\n Settings?: InstantBattleSettings;\r\n}\r\n\r\ninterface MatchEconomySettings {\r\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\r\n}\r\n```\r\n\r\n---\r\n\r\n## Combat formulas\r\n\r\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\r\nconfigures how a fighter's `FighterStats` are derived for a battle.\r\n\r\n```ts\r\ninterface InstantBattleSettings {\r\n StatMapping?: CombatStatMapping;\r\n Combat?: MatchCombatSettings;\r\n Formula?: MatchStatFormula;\r\n}\r\n\r\ninterface CombatStatMapping {\r\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\r\n DamageStatID?: string; // Default: \"Damage\"\r\n ArmorStatID?: string; // Default: \"Armor\"\r\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\r\n CritChanceStatID?: string; // Default: \"CritChance\"\r\n CritDamageStatID?: string; // Default: \"CritDamage\"\r\n DodgeStatID?: string; // Default: \"Speed\"\r\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\r\n}\r\n\r\ninterface MatchCombatSettings {\r\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\r\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\r\n MinHitDamage?: number; // floor for a hit after armor; default 1\r\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\r\n MaxCritChance?: number; // clamp; default 0.6\r\n MaxDodgeChance?: number; // clamp; default 0.4\r\n}\r\n\r\ninterface MatchStatFormula {\r\n Health?: FormulaSpec;\r\n Damage?: FormulaSpec;\r\n Armor?: FormulaSpec;\r\n AttackSpeed?: FormulaSpec;\r\n CritChance?: FormulaSpec;\r\n CritDamage?: FormulaSpec;\r\n Dodge?: FormulaSpec;\r\n}\r\n\r\ninterface FormulaSpec {\r\n Terms?: FormulaTerm[]; // the value = sum of terms\r\n}\r\n\r\ninterface FormulaTerm {\r\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\r\n Factors?: FormulaFactor[];\r\n}\r\n\r\ninterface FormulaFactor {\r\n Kind?: \"Constant\" | \"Variable\" | \"Curve\"; // default Constant\r\n Constant?: number; // Kind = Constant; empty = 1 (does not change the product)\r\n VariableID?: string; // Kind = Variable\r\n Argument?: string; // the variable's argument (a StatID, ...)\r\n Curve?: ScalarCurveSpec; // Kind = Curve, evaluated at the context's step\r\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\r\n}\r\n```\r\n\r\n⚠ `FormulaSpec` is a **platform** primitive and knows nothing about combat. The\r\nvocabulary of `VariableID` belongs to the MODULE; for instant battle it is\r\n`Stat`, `RankMultiplier`, `AllMight`, `GearFlat`, `GearPercent`, with `Argument`\r\ncarrying the `StatID` (an empty `Argument` on `Stat` means \"this role's own mapped\r\nstat\"). This replaced the old `FormulaSource` enum, which hard-coded those five\r\ncombat concepts inside the primitive.\r\n\r\n⚠ **An unknown `VariableID` means \"not computed\", not `0`.** A typo in the dashboard\r\ntherefore surfaces as \"my formula did not apply\" — visible and safe — rather than as a\r\nfighter silently walking into battle with 1 HP.\r\n\r\nA factor may carry a whole `ScalarCurveSpec` (`Kind: \"Curve\"`), but a curve can never\r\ncontain an expression. That is what makes the two layers acyclic by construction.\r\n\r\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\r\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\r\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\r\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\r\na concrete character `StatID` so the character's `StatLevels` (see\r\n`character-system` skill) feed into it — the same `StatID`s also key\r\nequipment flat/percent bonuses, so a remap automatically covers gear too.\r\nFactors reference base per-stat values and multipliers, never another role's\r\n_final_ value, so there are no formula cycles.\r\n\r\n**When a role has no custom formula** (`Formula` unset for that role), the\r\nengine falls back to its built-in default (backend\r\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\r\n\r\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\r\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\r\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight)\r\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\r\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\r\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\r\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\r\n\r\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\r\nscaling + character-rank scaling — see `character-system`\r\n`references/data-model.md`), `RankMultiplier` is the character's current\r\nrank's `RankStatCurve` value, `AllMight` is the raw (un-offset) AllMight\r\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\r\nequipped-item bonuses for that `StatID` (scaled by the item instance's\r\nupgrade level). **This is config for building previews/tooltips, not\r\nsomething to execute client-side to predict a battle outcome** — the server\r\nevaluates it; treat any client-side evaluation as an estimate only.\r\n\r\n---\r\n\r\n## Entry & creation settings\r\n\r\n```ts\r\ninterface MatchEntrySettings {\r\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\r\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\r\n AllowEventTokens?: boolean; // default false\r\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\r\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\r\n}\r\n\r\ninterface EntryResourceRule {\r\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\r\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\r\n CatalogID?: string; // when Kind === \"Item\"\r\n ItemID?: string; // when Kind === \"Item\"\r\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\r\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\r\n MinAmount?: number; // 0 = no lower bound\r\n MaxAmount?: number; // 0 = no upper bound\r\n}\r\n\r\ninterface MatchCreationSettings {\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\r\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\r\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\r\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\r\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\r\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\r\n}\r\n```\r\n\r\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\r\noffer currencies/items/event tokens the rule permits, and clamp the amount\r\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\r\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\r\nas an entry.\"` regardless of policy) — refunding/awarding would have to\r\nrecreate the item instance and lose its upgrade level. Duplicate positions\r\n(same currency, or same catalog+item, or same event-token address) submitted\r\nin one `Entry` are merged server-side before validation, so you don't need to\r\ndedupe client-side.\r\n\r\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\r\n— both can be charged on creation (merged into one `Consume.Standard` charge),\r\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\r\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\r\ncreation time) control only the creation fee on cancel; the entry cost itself\r\nis always refunded on a successful cancel. The creation fee is **always**\r\nsunk once a match is actually played (win, loss, or draw), regardless of the\r\nrefund flag. Don't assume what was refunded — read it off\r\n`CancelMatchResponse.Resources`, which reflects what the server actually\r\nreturned.\r\n\r\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\r\n`createMatch` when targeting a specific opponent, and the authoritative check\r\non `instantBattle` (both directions of the pair, UTC calendar day, counting\r\n`Completed` matches) — a private challenge can still be rejected at battle\r\ntime even if it passed at creation time if the pair played other matches in\r\nbetween.\r\n\r\n---\r\n\r\n## Net reward / burn formula\r\n\r\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\r\neach of the loser's-and-winner's-combined entry positions and burns a share\r\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\r\n/ `CalculateNetReward`):\r\n\r\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\r\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\r\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\r\n rule's `Economy` is unset.\r\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\r\n exactly — no burn (items are indivisible; burning progress-style event\r\n tokens would be meaningless).\r\n\r\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\r\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\r\n\r\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\r\n\r\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\r\n leaves their balance and joins the pool); creator (winner) has\r\n `Grant.Standard = NetReward` (their own entry was already committed at\r\n `createMatch`, so only the reward is granted now).\r\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\r\n was already spent at `createMatch`, nothing more to take); joiner (winner)\r\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\r\n time) **and** `Grant.Standard = NetReward` in the same operation.\r\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\r\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\r\n never paid anything, so there's nothing to refund on their side. The\r\n creation fee is not refunded on a draw (it's sunk once played, per above).\r\n\r\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\r\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\r\nlogic branches on which one is present (see `MatchService.instantBattle` in\r\nSKILL.md's Gotchas).\r\n\r\n---\r\n\r\n## Battle strategy resolution\r\n\r\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\r\nwhichever side's profile is being built) resolve the strategy to use with the\r\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\r\n\r\n1. The `battleStrategy` passed in that specific request, if non-empty.\r\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\r\n non-empty.\r\n3. Otherwise a **freshly randomized** 3-step strategy (random\r\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\r\n — not persisted).\r\n\r\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\r\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\r\n\r\n---\r\n\r\n## Request shape\r\n\r\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\r\ninternally — useful context for reading error messages, not something you\r\nconstruct by hand:\r\n\r\n```ts\r\ninterface MatchRequest extends BaseRequest {\r\n MatchID?: string;\r\n TargetUserID?: string;\r\n Entry?: ResourceBundle;\r\n BattleStrategy?: BattleStepConfig[];\r\n CharacterID?: string;\r\n RuleID?: string;\r\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\r\n Page?: number;\r\n PageSize?: number;\r\n Statuses?: string[]; // GetMyMatches filter\r\n OnlyPublic?: boolean; // GetAvailableMatches filter\r\n}\r\n```\r\n\r\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\r\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\r\ncorrelation — informational, not something you need to read or set yourself.\r\n"
|
|
8
|
+
"content": "# Match data model — reference\n\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\nrequest/response types. All of these are **strictly typed in the SDK** —\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<MatchDefinitions>(\"Match\")` 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\nTerminology: the backend consistently calls the cost to participate **Entry**\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\nin the Match model. Use that vocabulary in any UI copy you generate.\n\n## Contents\n\n- [Player state](#player-state) — `UserMatchState`\n- [Match (offer)](#match-offer) — `PvPMatch`\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\n- [InstantBattleRule](#instantbattlerule)\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\n- [Net reward / burn formula](#net-reward--burn-formula)\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\n- [Request shape](#request-shape)\n\n---\n\n## Player state\n\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\nhydrates it at login.\n\n```ts\ninterface UserMatchState {\n PvPBattleStrategy?: BattleStepConfig[];\n CreationLimits?: UserMatchCreationLimitState | null;\n}\n\ninterface UserMatchCreationLimitState {\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\n DailyResetUtc?: string; // next UTC midnight reset\n}\n\ninterface BattleStepConfig {\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\n}\n```\n\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\nserver-side (`Match.CreationLimits` on the player document, written by\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\nfor a client to eventually show \"next match available in…\" UI, but nothing in\n`MatchService` currently reads it back into this cache slot — treat it as\ninformational/future until a response actually populates it for you.\n\n---\n\n## Match (offer)\n\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\nand `UpdateMatchResponse`.\n\n```ts\ninterface PvPMatch {\n MatchID: string;\n TitleID?: string;\n RuleID?: string;\n CreatedAt?: string;\n CreatorID?: string;\n CreatorCharacterID?: string;\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\n TargetUserID?: string; // set = private/targeted challenge; absent = public\n Entry?: ResourceBundle; // the creator's entry cost\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\n RefundCreationCostOnCancel?: boolean;\n JoinedByUserID?: string;\n JoinedByCharacterID?: string;\n JoinedAt?: string;\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\n WinnerUserID?: string; // absent/null on a draw\n CompletedAt?: string;\n IsRewardDistributed?: boolean;\n RewardDistributedAt?: string;\n}\n```\n\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\nmatch) are tracked separately — cancelling refunds the entry cost always, and\nthe creation fee only when `RefundCreationCostOnCancel` is true.\n\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\nresolves the battle synchronously in the same call, so a match goes directly\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\nenum for forward-compat / other match modes, not for instant-battle.\n\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\nmatch's strategy from `getMyMatches` or after you've fetched the match some\nother way; it isn't needed to join, since your own strategy is what you send\nto `instantBattle`.\n\n---\n\n## Battle result\n\nReturned inside `InstantBattleResponse.Battle`.\n\n```ts\ninterface BattleResult {\n WinnerUserID?: string; // absent on a draw\n LoserUserID?: string; // absent on a draw\n Entry?: ResourceBundle; // one side's entry cost that was in play\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\n IsDraw?: boolean;\n P1BattleProfile?: PlayerBattleProfile; // the match creator\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\n}\n\ninterface BattleLogEntry {\n RoundIndex?: number; // 1-based\n AttackerID?: string;\n DefenderID?: string;\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\n DefenderHpRemaining?: number; // floored at 0\n}\n\ninterface PlayerBattleProfile {\n UserID?: string;\n SelectedCharacterID?: string;\n SelectedCharacter?: CharacterModel; // see character-system skill\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\n Stats?: FighterStats; // final computed combat stats used for this fight\n}\n\ninterface FighterStats {\n MaxHp?: number; // starting HP, for a results-screen HP bar\n CurrentHp?: number; // HP at the end of the fight\n Damage?: number;\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\n CritChance?: number; // 0..MaxCritChance\n CritMultiplier?: number;\n Armor?: number; // meaning depends on ArmorMode: damage units (Flat) or a 0..MaxArmorReduction fraction (PercentReduction)\n DodgeChance?: number; // 0..MaxDodgeChance\n}\n```\n\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\nhit drops the defender to 0 HP, the defender does not get to act that round.\n`AttackZone`/`DefenseZone` per log entry come from each side's\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\nstrategy shorter than the battle simply repeats from the top.\n\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\n\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\n3. Apply armor per `ArmorMode`:\n - `Flat` (default): `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\n - `PercentReduction`: `potentialDamage = max(MinHitDamage, rawDamage * (1 - defender.Armor))`,\n with `Armor` already clamped to `[0, MaxArmorReduction]` in step 7 of `CalculateStats`.\n The `MinHitDamage` floor applies in both modes, so armor can never heal the defender.\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\n\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\n\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\nlevel-scaling snapshot the engine used internally — the backend\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\nthat depends on it being populated.\n\n`FighterStats` is the resolved combat stats each fighter fought with — read\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\none fight, not a live/cached character stat.\n\n---\n\n## Config: MatchDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\n\n```ts\ninterface MatchDefinitions {\n InstantBattle?: InstantBattleDefinitions;\n}\n\ninterface InstantBattleDefinitions {\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\n Defaults?: InstantBattleSettings; // title-wide combat fallback\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\n}\n```\n\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\nthere's no other battle mode in the model today. If the title hasn't\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\n— so there is always at least one valid `RuleID` to pass.\n\nResolution order for every block is **rule's own → title `Defaults` (or\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\ninline-over-preset pattern the character module uses. `StatMapping` resolves\nper-field (each role can come from a different layer); `Combat`, `Entry`,\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\ntitle's `EntryDefaults`, even for fields it left unset).\n\n---\n\n## InstantBattleRule\n\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\n\n```ts\ninterface InstantBattleRule {\n RuleID?: string;\n DisplayName?: string;\n Description?: string;\n Economy?: MatchEconomySettings;\n Entry?: MatchEntrySettings;\n Creation?: MatchCreationSettings;\n Settings?: InstantBattleSettings;\n}\n\ninterface MatchEconomySettings {\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\n}\n```\n\n---\n\n## Combat formulas\n\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\nconfigures how a fighter's `FighterStats` are derived for a battle.\n\n```ts\ninterface InstantBattleSettings {\n StatMapping?: CombatStatMapping;\n Combat?: MatchCombatSettings;\n Formula?: MatchStatFormula;\n}\n\ninterface CombatStatMapping {\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\n DamageStatID?: string; // Default: \"Damage\"\n ArmorStatID?: string; // Default: \"Armor\"\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\n CritChanceStatID?: string; // Default: \"CritChance\"\n CritDamageStatID?: string; // Default: \"CritDamage\"\n DodgeStatID?: string; // Default: \"Speed\"\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\n}\n\ninterface MatchCombatSettings {\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\n MinHitDamage?: number; // floor for a hit after armor; default 1\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\n MaxCritChance?: number; // clamp; default 0.6\n MaxDodgeChance?: number; // clamp; default 0.4\n ArmorMode?: \"Flat\" | \"PercentReduction\"; // how armor reduces damage; default 'Flat'\n MaxArmorReduction?: number; // clamp in PercentReduction mode only (lower bound 0); default 0.9\n}\n\ninterface MatchStatFormula {\n Health?: FormulaSpec;\n Damage?: FormulaSpec;\n Armor?: FormulaSpec;\n AttackSpeed?: FormulaSpec;\n CritChance?: FormulaSpec;\n CritDamage?: FormulaSpec;\n Dodge?: FormulaSpec;\n}\n\ninterface FormulaSpec {\n Terms?: FormulaTerm[]; // the value = sum of terms\n}\n\ninterface FormulaTerm {\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\n Factors?: FormulaFactor[];\n}\n\ninterface FormulaFactor {\n Kind?: \"Constant\" | \"Variable\" | \"Curve\"; // default Constant\n Constant?: number; // Kind = Constant; empty = 1 (does not change the product)\n VariableID?: string; // Kind = Variable\n Argument?: string; // the variable's argument (a StatID, ...)\n Curve?: ScalarCurveSpec; // Kind = Curve, evaluated at the context's step\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\n}\n```\n\n⚠ `FormulaSpec` is a **platform** primitive and knows nothing about combat. The\nvocabulary of `VariableID` belongs to the MODULE; for instant battle it is\n`Stat`, `RankMultiplier`, `AllMight`, `GearFlat`, `GearPercent`, with `Argument`\ncarrying the `StatID` (an empty `Argument` on `Stat` means \"this role's own mapped\nstat\"). This replaced the old `FormulaSource` enum, which hard-coded those five\ncombat concepts inside the primitive.\n\n⚠ **An unknown `VariableID` means \"not computed\", not `0`.** A typo in the dashboard\ntherefore surfaces as \"my formula did not apply\" — visible and safe — rather than as a\nfighter silently walking into battle with 1 HP.\n\nA factor may carry a whole `ScalarCurveSpec` (`Kind: \"Curve\"`), but a curve can never\ncontain an expression. That is what makes the two layers acyclic by construction.\n\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\na concrete character `StatID` so the character's `StatLevels` (see\n`character-system` skill) feed into it — the same `StatID`s also key\nequipment flat/percent bonuses, so a remap automatically covers gear too.\nFactors reference base per-stat values and multipliers, never another role's\n_final_ value, so there are no formula cycles.\n\n**When a role has no custom formula** (`Formula` unset for that role), the\nengine falls back to its built-in default (backend\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\n\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight), then clamped to\n `[0, MaxArmorReduction]` when `ArmorMode` is `PercentReduction` (never clamped in `Flat`)\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\n\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\nscaling + character-rank scaling — see `character-system`\n`references/data-model.md`), `RankMultiplier` is the character's current\nrank's `RankStatCurve` value, `AllMight` is the raw (un-offset) AllMight\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\nequipped-item bonuses for that `StatID` (scaled by the item instance's\nupgrade level). **This is config for building previews/tooltips, not\nsomething to execute client-side to predict a battle outcome** — the server\nevaluates it; treat any client-side evaluation as an estimate only.\n\n---\n\n## Entry & creation settings\n\n```ts\ninterface MatchEntrySettings {\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\n AllowEventTokens?: boolean; // default false\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\n}\n\ninterface EntryResourceRule {\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\n CatalogID?: string; // when Kind === \"Item\"\n ItemID?: string; // when Kind === \"Item\"\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\n MinAmount?: number; // 0 = no lower bound\n MaxAmount?: number; // 0 = no upper bound\n}\n\ninterface MatchCreationSettings {\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\n}\n```\n\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\noffer currencies/items/event tokens the rule permits, and clamp the amount\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\nas an entry.\"` regardless of policy) — refunding/awarding would have to\nrecreate the item instance and lose its upgrade level. Duplicate positions\n(same currency, or same catalog+item, or same event-token address) submitted\nin one `Entry` are merged server-side before validation, so you don't need to\ndedupe client-side.\n\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\n— both can be charged on creation (merged into one `Consume.Standard` charge),\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\ncreation time) control only the creation fee on cancel; the entry cost itself\nis always refunded on a successful cancel. The creation fee is **always**\nsunk once a match is actually played (win, loss, or draw), regardless of the\nrefund flag. Don't assume what was refunded — read it off\n`CancelMatchResponse.Resources`, which reflects what the server actually\nreturned.\n\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\n`createMatch` when targeting a specific opponent, and the authoritative check\non `instantBattle` (both directions of the pair, UTC calendar day, counting\n`Completed` matches) — a private challenge can still be rejected at battle\ntime even if it passed at creation time if the pair played other matches in\nbetween.\n\n---\n\n## Net reward / burn formula\n\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\neach of the loser's-and-winner's-combined entry positions and burns a share\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\n/ `CalculateNetReward`):\n\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\n rule's `Economy` is unset.\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\n exactly — no burn (items are indivisible; burning progress-style event\n tokens would be meaningless).\n\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\n\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\n\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\n leaves their balance and joins the pool); creator (winner) has\n `Grant.Standard = NetReward` (their own entry was already committed at\n `createMatch`, so only the reward is granted now).\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\n was already spent at `createMatch`, nothing more to take); joiner (winner)\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\n time) **and** `Grant.Standard = NetReward` in the same operation.\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\n never paid anything, so there's nothing to refund on their side. The\n creation fee is not refunded on a draw (it's sunk once played, per above).\n\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\nlogic branches on which one is present (see `MatchService.instantBattle` in\nSKILL.md's Gotchas).\n\n---\n\n## Battle strategy resolution\n\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\nwhichever side's profile is being built) resolve the strategy to use with the\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\n\n1. The `battleStrategy` passed in that specific request, if non-empty.\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\n non-empty.\n3. Otherwise a **freshly randomized** 3-step strategy (random\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\n — not persisted).\n\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\n\n---\n\n## Request shape\n\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\ninternally — useful context for reading error messages, not something you\nconstruct by hand:\n\n```ts\ninterface MatchRequest extends BaseRequest {\n MatchID?: string;\n TargetUserID?: string;\n Entry?: ResourceBundle;\n BattleStrategy?: BattleStepConfig[];\n CharacterID?: string;\n RuleID?: string;\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\n Page?: number;\n PageSize?: number;\n Statuses?: string[]; // GetMyMatches filter\n OnlyPublic?: boolean; // GetAvailableMatches filter\n}\n```\n\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\ncorrelation — informational, not something you need to read or set yourself.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -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
|
}
|