@idosgames/mcp 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +19 -15
- 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/character-system.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/lootbox-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +1 -1
- package/registry/skills/purchase-system.json +11 -0
- package/registry/skills/referral-system.json +1 -1
- package/registry/skills/reward-system.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idosgames/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/registry/host.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"path": "package.json",
|
|
14
|
-
"content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.1.
|
|
14
|
+
"content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.1.14\",\n \"@idosgames/core\": \"0.8.0\",\n \"@idosgames/module-sdk\": \"0.1.9\",\n \"@idosgames/react\": \"0.2.1\",\n \"@idosgames/wallet\": \"0.2.1\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"path": "src/config.ts",
|
package/registry/index.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedFromCommit": "
|
|
2
|
+
"generatedFromCommit": "20f51540651637e324dd649218d240bc786e3836",
|
|
3
3
|
"runtimePackages": {
|
|
4
|
-
"@idosgames/core": "0.
|
|
5
|
-
"@idosgames/wallet": "0.2.
|
|
6
|
-
"@idosgames/module-sdk": "0.1.
|
|
7
|
-
"@idosgames/react": "0.2.
|
|
8
|
-
"@idosgames/app-shell": "0.1.
|
|
4
|
+
"@idosgames/core": "0.8.0",
|
|
5
|
+
"@idosgames/wallet": "0.2.1",
|
|
6
|
+
"@idosgames/module-sdk": "0.1.9",
|
|
7
|
+
"@idosgames/react": "0.2.1",
|
|
8
|
+
"@idosgames/app-shell": "0.1.14"
|
|
9
9
|
},
|
|
10
10
|
"host": {
|
|
11
11
|
"id": "host-starter",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"version": "0.1.0",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@idosgames/core": "0.
|
|
50
|
-
"@idosgames/module-sdk": "0.1.
|
|
51
|
-
"@idosgames/react": "0.2.
|
|
52
|
-
"@idosgames/wallet": "0.2.
|
|
49
|
+
"@idosgames/core": "0.8.0",
|
|
50
|
+
"@idosgames/module-sdk": "0.1.9",
|
|
51
|
+
"@idosgames/react": "0.2.1",
|
|
52
|
+
"@idosgames/wallet": "0.2.1",
|
|
53
53
|
"@tanstack/react-query": "5.101.2",
|
|
54
54
|
"react": "19.2.7",
|
|
55
55
|
"three": "0.185.1",
|
|
@@ -92,10 +92,10 @@
|
|
|
92
92
|
},
|
|
93
93
|
"version": "0.1.0",
|
|
94
94
|
"dependencies": {
|
|
95
|
-
"@idosgames/core": "0.
|
|
96
|
-
"@idosgames/module-sdk": "0.1.
|
|
97
|
-
"@idosgames/react": "0.2.
|
|
98
|
-
"@idosgames/wallet": "0.2.
|
|
95
|
+
"@idosgames/core": "0.8.0",
|
|
96
|
+
"@idosgames/module-sdk": "0.1.9",
|
|
97
|
+
"@idosgames/react": "0.2.1",
|
|
98
|
+
"@idosgames/wallet": "0.2.1",
|
|
99
99
|
"@tanstack/react-query": "5.101.2",
|
|
100
100
|
"phaser": "4.2.1",
|
|
101
101
|
"react": "19.2.7",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
"version": "0.1.0",
|
|
140
140
|
"dependencies": {
|
|
141
|
-
"@idosgames/module-sdk": "0.1.
|
|
141
|
+
"@idosgames/module-sdk": "0.1.9",
|
|
142
142
|
"three": "0.185.1"
|
|
143
143
|
},
|
|
144
144
|
"fileCount": 38
|
|
@@ -245,6 +245,10 @@
|
|
|
245
245
|
"name": "premium-system",
|
|
246
246
|
"description": "Build a premium / subscription / VIP-tier system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.premium (PremiumService): load premium tier definitions, load the player's active subscriptions, activate a free trial, purchase a premium tier with virtual/item cost, or complete a real-money IAP subscription purchase (App Store / Google Play receipt validation). This is the player's subscription/IAP tier that other modules (Store cost discounts via ResourceConsume.PremiumDiscounts, reward/lootbox grant multipliers via ResourceGrant.PremiumTiers, segment gates via SegmentGate.MinPremiumTier) read to unlock perks. Use whenever the user wants a subscription/VIP/battle-pass-tier paywall, IAP receipt validation, trial flows, or touches client.premium, PremiumService, PremiumDefinition, MaxActiveTier, or premium discounts/multipliers — even if they don't name the module explicitly."
|
|
247
247
|
},
|
|
248
|
+
{
|
|
249
|
+
"name": "purchase-system",
|
|
250
|
+
"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."
|
|
251
|
+
},
|
|
248
252
|
{
|
|
249
253
|
"name": "quest-system",
|
|
250
254
|
"description": "Build a quest / daily-task system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.quest (QuestService): load quest and cycle definitions, load the player's quest progress state, add progress toward a metric, claim a completed quest's reward, claim a points-track milestone reward, claim a group-completion (grand) reward, and refresh cycles (dailies/ weeklies) forward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest screens, task lists, objective/progress trackers, battle-pass-style points tracks, milestone reward ladders, quest-group completion bonuses, or otherwise touches client.quest, QuestService, QuestDefinitions, UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they don't name the module explicitly."
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.2.
|
|
43
|
-
"@idosgames/wallet": "0.2.
|
|
40
|
+
"@idosgames/core": "0.8.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.9",
|
|
42
|
+
"@idosgames/react": "0.2.1",
|
|
43
|
+
"@idosgames/wallet": "0.2.1",
|
|
44
44
|
"@tanstack/react-query": "5.101.2",
|
|
45
45
|
"react": "19.2.7",
|
|
46
46
|
"three": "0.185.1",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.2.
|
|
43
|
-
"@idosgames/wallet": "0.2.
|
|
40
|
+
"@idosgames/core": "0.8.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.9",
|
|
42
|
+
"@idosgames/react": "0.2.1",
|
|
43
|
+
"@idosgames/wallet": "0.2.1",
|
|
44
44
|
"@tanstack/react-query": "5.101.2",
|
|
45
45
|
"phaser": "4.2.1",
|
|
46
46
|
"react": "19.2.7",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
"path": "data/characterConfig.ts",
|
|
81
|
-
"content": "import type { IDosGamesClient } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n
|
|
81
|
+
"content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\r\nimport { curveMultiplier, evaluateCurve, roundAmount } from \"@idosgames/core\";\r\n\r\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\r\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\r\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\r\n// see Core/Presets/Models/PresetBinding.cs on the backend).\r\n\r\nexport interface PresetBinding {\r\n PresetID?: string;\r\n Remove?: string[];\r\n}\r\n\r\nexport interface EquipmentSlotRule {\r\n SlotID?: string;\r\n MinCharacterLevel?: number;\r\n AllowedItemTags?: string[];\r\n AllowedRarityIDs?: string[];\r\n MaxItemLevel?: number;\r\n MinItemLevel?: number;\r\n}\r\n\r\nexport interface EquipmentPreset {\r\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\r\n}\r\n\r\nexport interface PriceOption {\r\n Cost?: { Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] } };\r\n}\r\n\r\nexport interface StatDefinition {\r\n StatID?: string;\r\n DisplayName?: string;\r\n MaxLevel?: number;\r\n BaseStatValue?: number;\r\n /** Growth of the value over the stat's own level; the step is numbered from **0**. */\r\n ValueCurve?: ScalarCurveSpec;\r\n /** Cost curve; the step is the TARGET level, numbered from 1. */\r\n CostCurve?: ScalarCurveSpec;\r\n /** Ways to pay; key = the option id. Replaced the removed `BaseCostResource`. */\r\n PriceOptions?: Record<string, PriceOption>;\r\n}\r\n\r\nexport interface StatsPreset {\r\n Stats?: Record<string, StatDefinition>;\r\n}\r\n\r\nexport interface CharacterPresetBindings {\r\n Stats?: PresetBinding;\r\n Levels?: PresetBinding;\r\n Equipment?: PresetBinding;\r\n}\r\n\r\nexport interface CharacterDefinition {\r\n CharacterID?: string;\r\n Presets?: CharacterPresetBindings;\r\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\r\n Stats?: Record<string, StatDefinition>;\r\n Classification?: { ClassID?: string; RarityID?: string };\r\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\r\n Unlock?: { UnlockedByDefault?: boolean };\r\n}\r\n\r\nexport interface CharacterPresetRegistry {\r\n Stats?: Record<string, StatsPreset>;\r\n Equipment?: Record<string, EquipmentPreset>;\r\n}\r\n\r\nexport interface CharacterConfig {\r\n Definitions?: Record<string, CharacterDefinition>;\r\n Presets?: CharacterPresetRegistry;\r\n}\r\n\r\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\r\n return client.data.config.getSection<CharacterConfig>(\"Character\") ?? {};\r\n}\r\n\r\nexport interface SlotInfo {\r\n slotID: string;\r\n minCharacterLevel: number;\r\n}\r\n\r\n/**\r\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\r\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\r\n */\r\nexport function resolveSlots(\r\n config: CharacterConfig,\r\n def: CharacterDefinition | undefined,\r\n): SlotInfo[] {\r\n if (!def) return [];\r\n const binding = def.Presets?.Equipment;\r\n const presetSlots = binding?.PresetID\r\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\r\n : undefined;\r\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\r\n return Object.entries(slots).map(([slotID, rule]) => ({\r\n slotID,\r\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\r\n }));\r\n}\r\n\r\nexport interface StatInfo {\r\n statID: string;\r\n displayName: string;\r\n maxLevel?: number;\r\n baseValue: number;\r\n valueCurve?: ScalarCurveSpec;\r\n costCurve?: ScalarCurveSpec;\r\n costAmount: number;\r\n costCurrency?: string;\r\n}\r\n\r\n/**\r\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\r\n * StatID (preset base + inline override/add, Remove drops keys).\r\n */\r\nexport function resolveStats(\r\n config: CharacterConfig,\r\n def: CharacterDefinition | undefined,\r\n): StatInfo[] {\r\n if (!def) return [];\r\n const binding = def.Presets?.Stats;\r\n const presetStats = binding?.PresetID\r\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\r\n : undefined;\r\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\r\n return Object.entries(stats).map(([statID, rule]) => {\r\n // The first option's first entry, mirroring the server's \"default cost\" for shop-like\r\n // displays (`PriceOptionSelector.DefaultCost`): the player has not chosen a way to pay yet.\r\n const option = Object.values(rule.PriceOptions ?? {})[0];\r\n const entry = option?.Cost?.Standard?.Entries?.[0];\r\n return {\r\n statID,\r\n displayName: rule.DisplayName ?? statID,\r\n maxLevel: rule.MaxLevel,\r\n baseValue: rule.BaseStatValue ?? 0,\r\n valueCurve: rule.ValueCurve,\r\n costCurve: rule.CostCurve,\r\n costAmount: entry?.Amount ?? 0,\r\n costCurrency: entry?.CurrencyID,\r\n };\r\n });\r\n}\r\n\r\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\r\nfunction mergeByKey<T>(\r\n preset: Record<string, T> | undefined,\r\n inline: Record<string, T> | undefined,\r\n remove: string[] | undefined,\r\n): Record<string, T> {\r\n if (!preset) return inline ?? {};\r\n const result: Record<string, T> = { ...preset, ...inline };\r\n for (const key of remove ?? []) delete result[key];\r\n return result;\r\n}\r\n\r\n/**\r\n * Effective stat value at a given level — the shared curve, evaluated exactly as the\r\n * server does it in `PvPBattleEngine.CalculateStats`.\r\n *\r\n * The step is the stat level with **firstStep = 0**: a stat the player never upgraded is\r\n * level 0 and is worth the plain base. This used to be a third hand-written copy of the\r\n * formula here, and it was wrong in two ways at once — it assumed the multiplicative\r\n * shape (the server's is whatever the publisher configured) and counted from level 1, so\r\n * every stat was displayed one step behind what the battle actually used.\r\n */\r\nexport function computeStatValue(stat: StatInfo, level: number): number {\r\n return evaluateCurve(stat.valueCurve, stat.baseValue, level, 0);\r\n}\r\n\r\n/**\r\n * Cost to upgrade a stat TO `nextLevel`. The step is the TARGET level, numbered from 1,\r\n * and the total is rounded UP once — the platform's single rounding convention. The old\r\n * `Math.round` here disagreed with the server by one unit on half the levels.\r\n */\r\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\r\n if (stat.costAmount <= 0) return 0;\r\n return roundAmount(stat.costAmount * curveMultiplier(stat.costCurve, nextLevel, 1));\r\n}\r\n\r\nexport type Availability = \"owned\" | \"available\" | \"locked\";\r\n\r\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\r\n * (first level-up creates the model); locked = must be unlocked. */\r\nexport function resolveAvailability(\r\n def: CharacterDefinition | undefined,\r\n hasModel: boolean,\r\n): Availability {\r\n if (hasModel) return \"owned\";\r\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\r\n return \"locked\";\r\n}\r\n"
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"path": "env.ts",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterLevelDefinition`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` 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 JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterLevelDefinition (ranks)](#characterleveldefinition-ranks)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<\n string,\n { Levels?: Record<string, CharacterLevelDefinition> }\n >;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n PriceOptions?: Record<string, PriceOption>; // empty/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n Levels?: Record<string, CharacterLevelDefinition>; // \"1\",\"2\",... -> rank\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `PriceOptions` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `PriceOptions` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n## Prices are `PriceOptions`, not a single cost\n\nEvery price in this module is a **dictionary of payment options** (`PriceOptions`), keyed by\n`OptionID`. One option = one way to pay; the entries inside an option's `Cost` are charged\ntogether. Pick one with `SelectedOptionID`, or omit it and the server takes the first option\navailable on the caller's platform — which is why a single-price character needs no client change.\n\nRender options through `client.checkout.availableOptions(...)`: an option may be restricted to\nsome platforms (`AllowedPlatforms`), and an option whose `Cost` holds a `Purchase` entry is paid\nwith a **store receipt**, not from balances. Unlock accepts store payment; stat and rank upgrades\ndo not — their price grows by a formula per level, and a store SKU is a fixed tier. See the\n`checkout-system` skill.\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n PriceOptions: Record<string, PriceOption>; // ways to pay for level 1 (must be non-empty)\n CostScalingFactor: number; // linear cost growth per level\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n StatScalingFactor: number; // flat value added per stat level (additive, not a %)\n CharacterLevelScalingFactor: number; // fractional per-rank value growth (multiplicative)\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nFormulas (the backend applies these; use them for previews):\n\n- **Cost of level `N`** (N = target level, 1-based): each entry's base `Amount`\n scaled by `Amount * (1 + CostScalingFactor * (N - 1))`, rounded to the nearest\n whole amount.\n- **Effective max level** = `floor(MaxLevel * StatMaxLevelMultiplier)` where the\n multiplier comes from the character's _current_ rank\n (`CharacterLevelDefinition.StatMaxLevelMultiplier`, default 1). This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** =\n `(BaseStatValue + N * StatScalingFactor) * (1 + CharacterLevelScalingFactor * (R - 1))`.\n The stat part grows **additively** (`N = 0` before the first upgrade, so the\n base value is what a fresh character has); the rank part multiplicatively.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\nAn option's `Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterLevelDefinition (ranks)\n\nConfig for one character Level/rank. Key in `Levels` is the level number as a\nstring (`\"1\"`, `\"2\"`, ...). Level `0` = uninitialized, has no config.\n\n```ts\ninterface CharacterLevelDefinition {\n Level: number;\n PriceOptions?: Record<string, PriceOption>; // ways to pay for this level; empty/absent = free\n GlobalStatMultiplier?: number; // multiplies the character's stat values (and thus Power) at this rank (1.0 = none)\n StatMaxLevelMultiplier?: number; // raises every stat's cap at this rank\n AssetPaths?: Record<string, string>;\n}\n```\n\n`upgradeCharacterLevel` moves the character from its current level to the next\none and charges the selected option of that level's `PriceOptions`.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the rank's `GlobalStatMultiplier`;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
|
|
8
|
+
"content": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterRankLadder`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` 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 JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterRankLadder (ranks)](#characterrankladder-the-rank-ladder)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<string, { RankLadder?: CharacterRankLadder }>;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n PriceOptions?: Record<string, PriceOption>; // empty/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n RankLadder?: CharacterRankLadder; // how many ranks and how the price grows\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `PriceOptions` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `PriceOptions` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n## Prices are `PriceOptions`, not a single cost\n\nEvery price in this module is a **dictionary of payment options** (`PriceOptions`), keyed by\n`OptionID`. One option = one way to pay; the entries inside an option's `Cost` are charged\ntogether. Pick one with `SelectedOptionID`, or omit it and the server takes the first option\navailable on the caller's platform — which is why a single-price character needs no client change.\n\nRender options through `client.checkout.availableOptions(...)`: an option may be restricted to\nsome platforms (`AllowedPlatforms`), and an option whose `Cost` holds a `Purchase` entry is paid\nwith a **store receipt**, not from balances. Unlock accepts store payment; stat and rank upgrades\ndo not — their price grows by a formula per level, and a store SKU is a fixed tier. See the\n`checkout-system` skill.\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n PriceOptions: Record<string, PriceOption>; // ways to pay for level 1 (must be non-empty)\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n ValueCurve?: ScalarCurveSpec; // growth over the stat's OWN level; step from 0\n RankCurve?: ScalarCurveSpec; // growth over the character's rank; step from 1\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nEvery growth here is a `ScalarCurveSpec` — the platform's shared curve. **An unset\ncurve is the identity**, so a stat with no `ValueCurve` simply does not grow when\nupgraded; there is no field whose neutral value is `1`. The shapes are `Flat`,\n`PerStep` (units per step), `PerStepRate` (share of the base per step), `Geometric`,\n`Table`, `TableAbsolute`. See the `curve-system` notes in the core package\n(`evaluateCurve`, exported from `@idosgames/core`) rather than re-deriving them.\n\nFormulas (the backend applies these; use `evaluateCurve` for previews):\n\n- **Cost of level `N`** (N = target level): each entry's base `Amount` put through\n `CostCurve` with `firstStep = 1`, rounded **UP**, once, at the end.\n- **Effective max level** = `MaxLevel` put through the character's\n `StatMaxLevelCurve` at its current rank (`firstStep = 1`), rounded up. This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** = `ValueCurve(BaseStatValue, N)`\n with **`firstStep = 0`**, then `RankCurve(thatValue, R)` with `firstStep = 1`, then the\n character-wide `RankStatCurve(R)`.\n ⚠ The stat level counts from **0** — a stat the player never upgraded is level 0 and is\n worth the plain base — while ranks, prices and item levels count from 1. Using the wrong\n first step shifts every value one step along the curve.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\nAn option's `Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterRankLadder (the rank ladder)\n\n```ts\ninterface CharacterRankLadder {\n MaxRank?: number; // how many ranks in total; empty = no ceiling\n FirstPaidRank?: number; // ranks below it are free; empty = 1\n PriceOptions?: Record<string, PriceOption>; // price of the first PAID rank\n CostCurve?: ScalarCurveSpec; // price growth; step = the rank, FirstPaidRank = first step\n}\n```\n\nLives on `CharacterDefinition.RankLadder`, or on the levels preset (`LevelsPreset.RankLadder`)\nwhen the character binds one. **It is the only source of rank prices** — there is no per-rank\ndictionary, and a character without a ladder cannot be ranked up at all.\n\nThe inline ladder **replaces** the preset one whole rather than merging field by field: it is one\nobject (\"how many ranks and how the price grows\"), and taking the ceiling from one place and the\nprice from another has no meaning.\n\n⚠ **A non-uniform ladder is expressed by the curve shape**, not by per-rank entries: `Table` and\n`TableAbsolute` place a multiplier per rank. What a ladder cannot express is a different price\nCOMPOSITION per rank (rank 5 paid in a special item, the rest in gold) — the composition is fixed\nby `PriceOptions` and the curve only scales the amount.\n\n⚠ **`FirstPaidRank` is how a free activation is expressed.** Level `0` means the character exists\nbut is not activated; in most titles the 0 → 1 step is free because the character itself is paid\nfor in `Unlock.PriceOptions`. Ranks below `FirstPaidRank` cost nothing and do **not** stop the\nclimb. The curve starts counting at `FirstPaidRank`, so the base is the price of the first rank\nthat actually costs something.\n\n`upgradeCharacterLevel` moves the character from its current level to the next one and charges the\nselected option of the ladder's `PriceOptions`, scaled by `CostCurve` at that rank.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the character's `RankStatCurve` at the current rank;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Item data model — reference\n\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\n- [ItemCatalog](#itemcatalog)\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\n- [ItemDefinition](#itemdefinition)\n- [ItemStats](#itemstats)\n- [ItemEquipment](#itemequipment)\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\n- [ItemMetadata](#itemmetadata)\n- [NFTModel](#nftmodel)\n- [Player state: InventoryV2](#player-state-inventoryv2)\n- [Requests, responses, and actions](#requests-responses-and-actions)\n\n---\n\n## Config: ItemDefinitions\n\nRoot container for every item catalog in the title.\n\n```ts\ninterface ItemDefinitions {\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\n}\n```\n\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\nthe exported type is pinned to a hand-written interface instead.\n\n## ItemCatalog\n\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\n\n```ts\ninterface ItemCatalog {\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\n}\n```\n\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\nguaranteed unique **within** a catalog — the same `ItemID` string can\nlegitimately appear in more than one catalog, which is exactly what the\nresolution rule below has to handle.\n\n## Catalog resolution rule\n\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\ncall this yourself, but its behavior explains error messages and a\nself-healing field you'll see on upgrade responses:\n\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\n unambiguous by construction.\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\n (the item was moved to a different catalog since the instance was granted),\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\n that's the resolved definition.\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\n more** catalogs, the resolver refuses to guess and returns nothing (the\n caller reports \"item definition not found\").\n\n**Self-heal:** when resolution succeeds via the fallback path with a\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\none as part of the same atomic write — silently, no separate event. That's why\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\nthe instance before calling upgrade: read it back off the response / refreshed\ncache, don't assume it's unchanged.\n\n---\n\n## ItemDefinition\n\nThe template from which player instances are created.\n\n```ts\ninterface ItemDefinition {\n ItemID: string;\n CatalogID: string;\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\n DisplayName?: string;\n Description?: string;\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\n CustomData?: string;\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\n NFT?: NFTModel; // blockchain binding, if any\n Stats?: ItemStats;\n Equipment?: ItemEquipment;\n Upgrade?: ItemUpgrade;\n Metadata?: ItemMetadata;\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\n}\n```\n\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\nconsumable can still have upgrade tiers, and an equippable item can be\nnon-upgradable.\n\n---\n\n## ItemStats\n\nStat modifiers/Power the item contributes when equipped. Applied in two\nlayers — flat bonuses added to the base stat first, then percent bonuses\nmultiply the (base + flat) total. Consumed by the Character module's Power\ncomputation (see character-system skill) — never recomputed client-side.\n\n```ts\ninterface ItemStats {\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\n}\n```\n\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\n\n---\n\n## ItemEquipment\n\nThe item-side half of the two-sided equip rule matrix (the character-side\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\nreference doc — both must pass for an equip to succeed).\n\n```ts\ninterface ItemEquipment {\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\n UseRequirements?: Record<string, number>; // statID -> required character stat level\n AllowedCharacterIDs?: string[]; // null/empty = any character\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\n}\n```\n\n---\n\n## ItemUpgrade + cost formula\n\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\n`upgradeLevelsBatch`.\n\n```ts\ninterface ItemUpgrade {\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\n CostScalingFactor?: number; // linear cost growth per level; 0 = flat cost at every level\n FlatScalingFactor?: number; // ItemStats.FlatBonuses growth per level\n PercentScalingFactor?: number; // ItemStats.PercentBonuses growth per level\n PowerScalingFactor?: number; // ItemStats.Power growth per level\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\n}\n```\n\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\nnot the step count):\n\n```\nAmount(N) = round(BaseCost.Amount * (1 + CostScalingFactor * (N - 1)))\n// BaseCost = the Cost of the selected PriceOptions option\n```\n\n— identical semantics to the Character module's stat-cost scaling. `N - 1` means\nthe level-1→2 step costs exactly the base cost, unscaled. A multi-level upgrade\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\nfrom `current + 1` through the resolved target — it is not a single jump priced\noff the destination level alone. If every scaled amount rounds to `0`, or the\nselected option is empty, the upgrade is rejected as misconfigured rather than\ntreated as free.\n\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\nfor the first option available on the caller's platform.\n\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\nthrough unchanged and resolved by the shared premium pipeline per level before\nthe per-level bundles are summed — see the currency-system skill for\n`ResourceConsume`'s premium fields.\n\n**Stat/Power scaling at instance level `L`** (all use the same `1 + factor *\n(L - 1)` shape, base level is 1):\n\n- Flat bonus effective multiplier: `1 + FlatScalingFactor * (L - 1)` applied to\n each `ItemStats.FlatBonuses` value.\n- Percent bonus effective multiplier: `1 + PercentScalingFactor * (L - 1)`\n applied to each `ItemStats.PercentBonuses` value, before aggregation into\n the character's total gear-percent.\n- Effective Power: `round(ItemStats.Power * (1 + PowerScalingFactor * (L - 1)))`,\n added into `CharacterModel.Power` alongside stat-based Power (the two are\n simple sums — designers balance any double-counting themselves via weights).\n\nA `Factor` of `0` on any of these means that quantity does not grow with\nlevel — only the base value applies at every level. These three are read-only\ninputs to server computations (Power, PvP stat calc); the SDK never\nrecomputes them for you.\n\n---\n\n## Fodder valuation + selection modes\n\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\nand picked when the upgrade's own cost is expressed in copies of itself. Any\nother cost entries (currencies, other items, event tokens) are charged\nnormally through the regular resource pipeline regardless of `Fodder` config.\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\n`ProtectLeveled` selection.\n\n```ts\ninterface ItemUpgradeFodder {\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\n MergeRatio?: number; // used when ValuationMode === \"Merge\"; typical 2..5\n Selection?: \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\";\n}\n```\n\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\n\nThe server expresses the self-item portion of the upgrade cost as a **target\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\nburns fodder copies until their summed `W(level)` meets or exceeds that\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\n\n| Mode | `W(L)` formula | Notes |\n| ------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\n| `Merge` | `W(L) = round(MergeRatio ^ (L - 1))`, rounded away from zero | `MergeRatio <= 1` degenerates to `W(L) = 1` (flat). Reads as \"`MergeRatio` copies of level `L` ≈ one copy of level `L+1`\". |\n| `InvestmentRefund` | `W(L) = 1 + Σ_{k=2}^{L} round(BaseSelfAmount * (1 + CostScalingFactor * (k - 1)))`, each term rounded away from zero | `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\n\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\nclient-side for a cost preview, but the server is what actually enforces\ncoverage.\n\n### Selection — which instances get burned\n\nOnly relevant when the caller doesn't already specify exact fodder for every\nunit needed (or when supply must be chosen automatically):\n\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\n eligible; anything the player has already leveled up is never auto-selected\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\n in the sense that the pool is still filtered this way in `FlatCount` mode\n (where fodder isn't weighted at all — see below).\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\n same-item/same-catalog, not equipped, not expired, not already claimed by\n another item in the same batch) are sorted by `W(level)` ascending, then by\n acquisition time, then by ID, and burned cheapest-first until the target\n value is covered. Leveled copies are eligible here and burn last (they're\n worth more per unit, so they're a poor early pick under this greedy order).\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\n validated individually (must exist, must be the same item/catalog, must not\n be equipped or expired, must not already be claimed elsewhere in the same\n batch) and rejected by name if any check fails. If the combined `W(level)`\n of your supplied instances doesn't cover the target, the whole upgrade is\n rejected — nothing is partially burned.\n\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\nthat mode the self-item cost is settled by the _regular_ resource-consume\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\nrather than being burned), and the rest come from plain inventory count, with\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\nself-item cost entry, or fodder that's a different item, is rejected.\n\n---\n\n## ItemMetadata\n\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\nsubsystem and general UI.\n\n```ts\ninterface ItemMetadata {\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\n CollectionID?: string; // ties the item into a Collection set/album page\n AuthorID?: string; // e.g. UGC/creator attribution\n}\n```\n\n---\n\n## NFTModel\n\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\nitem mirrored on both an EVM chain and Solana).\n\n```ts\ninterface NFTModel {\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\n}\n\ninterface NFTNetworkBinding {\n ContractAddress?: string; // EVM contract or Solana mint address\n TokenID?: string;\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\n}\n```\n\nSee the blockchain-system skill for the wallet/mint/transfer flows that\npopulate and consume this binding.\n\n---\n\n## Player state: InventoryV2\n\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\n`upgradeLevelsBatch` (and other inventory-affecting calls).\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\n RemainingUses?: number; // consumable-with-charges items\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\n AcquiredAt: string;\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\n CustomData?: string | null;\n}\n```\n\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\ncurrency-system skill; they ride along in the same inventory snapshot but\naren't item-related.\n\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\nwhich half of the inventory backs it.\n\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\none copy — e.g. leveling one copy of a stack of five identical swords — the\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\ncome back as a **different** id than the one you called with — always read\nthe instance id off the response (or the refreshed cache), don't assume it's\nunchanged. The backend also opportunistically re-merges pristine fragments of\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\nyou don't need to do anything to trigger or handle that.\n\n---\n\n## Requests, responses, and actions\n\n```ts\ninterface ItemRequest extends BaseRequest {\n ItemInstanceID?: string;\n Levels?: number;\n TargetLevel?: number;\n FodderInstanceIDs?: string[];\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\n Upgrades?: ItemUpgradeRef[];\n}\n```\n\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\n**not** expose them — it only ever raises by one level per call. To move\nseveral levels in one call (on one or many instances), use\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\n FodderInstanceIDs?: string[];\n}\n\ninterface FodderConsumedEntry {\n ItemInstanceID: string;\n Units: number; // how many copies of this instance/pack were burned\n Level: number; // the fodder instance's level at time of consumption\n}\n\ninterface UpgradeItemLevelResponse {\n ServerTimeUtc: string;\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\n ItemID: string;\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\n Level: number; // new level after the upgrade\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\n}\n\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\n```\n\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\nuseful when reading logs/errors that echo the action):\n\n```ts\nconst ItemAction = {\n UpgradeLevel: \"UpgradeLevel\",\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\n} as const;\n```\n\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\nshape used across the whole SDK — see the currency-system skill for the full\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\n`PremiumDiscounts` can reduce a displayed base cost.\n\n### Server-side limits (verified against the backend)\n\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\n appear in the result array at all. Chunk larger sets yourself.\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\n id in the same call only processes once.\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\n (single call fails outright; batch reports it as a failed item).\n- **Atomicity**: both the single and batch charge/patch happen inside one\n Mongo transaction — either the whole thing (cost + level + any fodder burns\n - owner Power recompute) applies, or none of it does.\n- **Idempotency**: the backend replays the same result for a repeated call\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\n server-side reason key, so re-running the _same target level_ twice is safe\n to retry). The TS `upgradeLevel` method, however, mints a fresh\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\n from the SDK's side, two separate calls are always two separate operations;\n see the Gotchas section in SKILL.md.\n"
|
|
8
|
+
"content": "# Item data model — reference\r\n\r\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\r\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\r\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\r\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\r\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\r\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\r\n`.passthrough()`, so a field the backend adds later still round-trips. Field\r\nnames are PascalCase (straight from the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\r\n- [ItemCatalog](#itemcatalog)\r\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\r\n- [ItemDefinition](#itemdefinition)\r\n- [ItemStats](#itemstats)\r\n- [ItemEquipment](#itemequipment)\r\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\r\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\r\n- [ItemMetadata](#itemmetadata)\r\n- [NFTModel](#nftmodel)\r\n- [Player state: InventoryV2](#player-state-inventoryv2)\r\n- [Requests, responses, and actions](#requests-responses-and-actions)\r\n\r\n---\r\n\r\n## Config: ItemDefinitions\r\n\r\nRoot container for every item catalog in the title.\r\n\r\n```ts\r\ninterface ItemDefinitions {\r\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\r\n}\r\n```\r\n\r\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\r\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\r\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\r\nthe exported type is pinned to a hand-written interface instead.\r\n\r\n## ItemCatalog\r\n\r\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\r\n\r\n```ts\r\ninterface ItemCatalog {\r\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\r\n}\r\n```\r\n\r\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\r\nguaranteed unique **within** a catalog — the same `ItemID` string can\r\nlegitimately appear in more than one catalog, which is exactly what the\r\nresolution rule below has to handle.\r\n\r\n## Catalog resolution rule\r\n\r\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\r\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\r\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\r\ncall this yourself, but its behavior explains error messages and a\r\nself-healing field you'll see on upgrade responses:\r\n\r\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\r\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\r\n unambiguous by construction.\r\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\r\n (the item was moved to a different catalog since the instance was granted),\r\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\r\n that's the resolved definition.\r\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\r\n more** catalogs, the resolver refuses to guess and returns nothing (the\r\n caller reports \"item definition not found\").\r\n\r\n**Self-heal:** when resolution succeeds via the fallback path with a\r\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\r\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\r\none as part of the same atomic write — silently, no separate event. That's why\r\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\r\nthe instance before calling upgrade: read it back off the response / refreshed\r\ncache, don't assume it's unchanged.\r\n\r\n---\r\n\r\n## ItemDefinition\r\n\r\nThe template from which player instances are created.\r\n\r\n```ts\r\ninterface ItemDefinition {\r\n ItemID: string;\r\n CatalogID: string;\r\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\r\n DisplayName?: string;\r\n Description?: string;\r\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\r\n CustomData?: string;\r\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\r\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\r\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\r\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\r\n NFT?: NFTModel; // blockchain binding, if any\r\n Stats?: ItemStats;\r\n Equipment?: ItemEquipment;\r\n Upgrade?: ItemUpgrade;\r\n Metadata?: ItemMetadata;\r\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\r\n}\r\n```\r\n\r\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\r\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\r\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\r\nconsumable can still have upgrade tiers, and an equippable item can be\r\nnon-upgradable.\r\n\r\n---\r\n\r\n## ItemStats\r\n\r\nStat modifiers/Power the item contributes when equipped. Applied in two\r\nlayers — flat bonuses added to the base stat first, then percent bonuses\r\nmultiply the (base + flat) total. Consumed by the Character module's Power\r\ncomputation (see character-system skill) — never recomputed client-side.\r\n\r\n```ts\r\ninterface ItemStats {\r\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\r\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\r\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\r\n}\r\n```\r\n\r\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\r\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\r\n\r\n---\r\n\r\n## ItemEquipment\r\n\r\nThe item-side half of the two-sided equip rule matrix (the character-side\r\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\r\nreference doc — both must pass for an equip to succeed).\r\n\r\n```ts\r\ninterface ItemEquipment {\r\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\r\n UseRequirements?: Record<string, number>; // statID -> required character stat level\r\n AllowedCharacterIDs?: string[]; // null/empty = any character\r\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\r\n}\r\n```\r\n\r\n---\r\n\r\n## ItemUpgrade + cost formula\r\n\r\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch`.\r\n\r\n```ts\r\ninterface ItemUpgrade {\r\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\r\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\r\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\r\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\r\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\r\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\r\n}\r\n```\r\n\r\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\r\nnot the step count):\r\n\r\n```\r\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\r\n// BaseCost = the Cost of the selected PriceOptions option\r\n```\r\n\r\n— identical semantics to the Character module's stat-cost scaling, and the same shared\r\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\r\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\r\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\r\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\r\nfrom `current + 1` through the resolved target — it is not a single jump priced\r\noff the destination level alone. If every scaled amount rounds to `0`, or the\r\nselected option is empty, the upgrade is rejected as misconfigured rather than\r\ntreated as free.\r\n\r\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\r\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\r\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\r\nfor the first option available on the caller's platform.\r\n\r\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\r\nthrough unchanged and resolved by the shared premium pipeline per level before\r\nthe per-level bundles are summed — see the currency-system skill for\r\n`ResourceConsume`'s premium fields.\r\n\r\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\r\nwith `firstStep = 1`, so level 1 is the plain base):\r\n\r\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\r\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\r\n before aggregation into the character's total gear-percent.\r\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\r\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\r\n balance any double-counting themselves via weights).\r\n\r\nAn **unset** curve means that quantity does not grow with level — only the base value\r\napplies at every level. There is no field here whose neutral value is `1`: empty is the\r\nneutral, always. These three are read-only\r\ninputs to server computations (Power, PvP stat calc); the SDK never\r\nrecomputes them for you.\r\n\r\n---\r\n\r\n## Fodder valuation + selection modes\r\n\r\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\r\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\r\nand picked when the upgrade's own cost is expressed in copies of itself. Any\r\nother cost entries (currencies, other items, event tokens) are charged\r\nnormally through the regular resource pipeline regardless of `Fodder` config.\r\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\r\n`ProtectLeveled` selection.\r\n\r\n```ts\r\ninterface ItemUpgradeFodder {\r\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\r\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\r\n Selection?: \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\";\r\n}\r\n```\r\n\r\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\r\n\r\nThe server expresses the self-item portion of the upgrade cost as a **target\r\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\r\nburns fodder copies until their summed `W(level)` meets or exceeds that\r\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\r\n\r\n| Mode | `W(L)` formula | Notes |\r\n| ------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\r\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\r\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\r\n\r\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\r\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\r\nclient-side for a cost preview, but the server is what actually enforces\r\ncoverage.\r\n\r\n### Selection — which instances get burned\r\n\r\nOnly relevant when the caller doesn't already specify exact fodder for every\r\nunit needed (or when supply must be chosen automatically):\r\n\r\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\r\n eligible; anything the player has already leveled up is never auto-selected\r\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\r\n in the sense that the pool is still filtered this way in `FlatCount` mode\r\n (where fodder isn't weighted at all — see below).\r\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\r\n same-item/same-catalog, not equipped, not expired, not already claimed by\r\n another item in the same batch) are sorted by `W(level)` ascending, then by\r\n acquisition time, then by ID, and burned cheapest-first until the target\r\n value is covered. Leveled copies are eligible here and burn last (they're\r\n worth more per unit, so they're a poor early pick under this greedy order).\r\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\r\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\r\n validated individually (must exist, must be the same item/catalog, must not\r\n be equipped or expired, must not already be claimed elsewhere in the same\r\n batch) and rejected by name if any check fails. If the combined `W(level)`\r\n of your supplied instances doesn't cover the target, the whole upgrade is\r\n rejected — nothing is partially burned.\r\n\r\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\r\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\r\nthat mode the self-item cost is settled by the _regular_ resource-consume\r\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\r\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\r\nrather than being burned), and the rest come from plain inventory count, with\r\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\r\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\r\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\r\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\r\nself-item cost entry, or fodder that's a different item, is rejected.\r\n\r\n---\r\n\r\n## ItemMetadata\r\n\r\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\r\nsubsystem and general UI.\r\n\r\n```ts\r\ninterface ItemMetadata {\r\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\r\n CollectionID?: string; // ties the item into a Collection set/album page\r\n AuthorID?: string; // e.g. UGC/creator attribution\r\n}\r\n```\r\n\r\n---\r\n\r\n## NFTModel\r\n\r\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\r\nitem mirrored on both an EVM chain and Solana).\r\n\r\n```ts\r\ninterface NFTModel {\r\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\r\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\r\n}\r\n\r\ninterface NFTNetworkBinding {\r\n ContractAddress?: string; // EVM contract or Solana mint address\r\n TokenID?: string;\r\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\r\n}\r\n```\r\n\r\nSee the blockchain-system skill for the wallet/mint/transfer flows that\r\npopulate and consume this binding.\r\n\r\n---\r\n\r\n## Player state: InventoryV2\r\n\r\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\r\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch` (and other inventory-affecting calls).\r\n\r\n```ts\r\ninterface UserInventoryState {\r\n Version?: number;\r\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\r\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\r\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\r\n ConversionDaily?: Record<string, ConversionDailyCounter>;\r\n}\r\n\r\ninterface ItemTotals {\r\n StackableAmount: number;\r\n UnstackableAmount: number;\r\n TotalAmount: number;\r\n}\r\n\r\ninterface UnstackableItemInstanceState {\r\n ItemInstanceID: string;\r\n ItemID: string;\r\n CatalogID?: string | null;\r\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\r\n RemainingUses?: number; // consumable-with-charges items\r\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\r\n AcquiredAt: string;\r\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\r\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\r\n CustomData?: string | null;\r\n}\r\n```\r\n\r\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\r\ncurrency-system skill; they ride along in the same inventory snapshot but\r\naren't item-related.\r\n\r\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\r\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\r\nwhich half of the inventory backs it.\r\n\r\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\r\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\r\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\r\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\r\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\r\none copy — e.g. leveling one copy of a stack of five identical swords — the\r\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\r\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\r\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\r\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\r\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\r\ncome back as a **different** id than the one you called with — always read\r\nthe instance id off the response (or the refreshed cache), don't assume it's\r\nunchanged. The backend also opportunistically re-merges pristine fragments of\r\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\r\nyou don't need to do anything to trigger or handle that.\r\n\r\n---\r\n\r\n## Requests, responses, and actions\r\n\r\n```ts\r\ninterface ItemRequest extends BaseRequest {\r\n ItemInstanceID?: string;\r\n Levels?: number;\r\n TargetLevel?: number;\r\n FodderInstanceIDs?: string[];\r\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\r\n Upgrades?: ItemUpgradeRef[];\r\n}\r\n```\r\n\r\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\r\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\r\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\r\n**not** expose them — it only ever raises by one level per call. To move\r\nseveral levels in one call (on one or many instances), use\r\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\r\n\r\n```ts\r\ninterface ItemUpgradeRef {\r\n ItemInstanceID?: string;\r\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\r\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\r\n FodderInstanceIDs?: string[];\r\n}\r\n\r\ninterface FodderConsumedEntry {\r\n ItemInstanceID: string;\r\n Units: number; // how many copies of this instance/pack were burned\r\n Level: number; // the fodder instance's level at time of consumption\r\n}\r\n\r\ninterface UpgradeItemLevelResponse {\r\n ServerTimeUtc: string;\r\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\r\n ItemID: string;\r\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\r\n Level: number; // new level after the upgrade\r\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\r\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\r\n}\r\n\r\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\r\n```\r\n\r\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\r\nuseful when reading logs/errors that echo the action):\r\n\r\n```ts\r\nconst ItemAction = {\r\n UpgradeLevel: \"UpgradeLevel\",\r\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\r\n} as const;\r\n```\r\n\r\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\r\nshape used across the whole SDK — see the currency-system skill for the full\r\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\r\n`PremiumDiscounts` can reduce a displayed base cost.\r\n\r\n### Server-side limits (verified against the backend)\r\n\r\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\r\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\r\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\r\n appear in the result array at all. Chunk larger sets yourself.\r\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\r\n id in the same call only processes once.\r\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\r\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\r\n (single call fails outright; batch reports it as a failed item).\r\n- **Atomicity**: both the single and batch charge/patch happen inside one\r\n Mongo transaction — either the whole thing (cost + level + any fodder burns\r\n - owner Power recompute) applies, or none of it does.\r\n- **Idempotency**: the backend replays the same result for a repeated call\r\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\r\n server-side reason key, so re-running the _same target level_ twice is safe\r\n to retry). The TS `upgradeLevel` method, however, mints a fresh\r\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\r\n from the SDK's side, two separate calls are always two separate operations;\r\n see the Gotchas section in SKILL.md.\r\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
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 CurveType?: \"Tiered\" | \"Linear\";\n Tiers?: { AtProgress?: number; Multiplier?: number }[]; // used when CurveType === \"Tiered\"\n TierMode?: \"Step\" | \"Linear\"; // interpolation between tiers\n BaseMultiplier?: number;\n PerUnit?: number; // used when CurveType === \"Linear\": multiplier grows per unit of progress\n Anchor?: number;\n MinMultiplier?: number;\n MaxMultiplier?: number;\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 the curve (`Tiered`: step or linear\n interpolation between `Tiers` breakpoints, clamped below the first/at-or-above\n the last; `Linear`: `BaseMultiplier + PerUnit * max(0, progress - Anchor)`),\n clamps to `[MinMultiplier, MaxMultiplier]` (`MaxMultiplier <= 0` means\n unbounded above), 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"
|
|
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"
|
|
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\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; // flat damage reduction\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. `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\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}\n\ninterface MatchStatFormula {\n Health?: CombatRoleFormula;\n Damage?: CombatRoleFormula;\n Armor?: CombatRoleFormula;\n AttackSpeed?: CombatRoleFormula;\n CritChance?: CombatRoleFormula;\n CritDamage?: CombatRoleFormula;\n Dodge?: CombatRoleFormula;\n}\n\ninterface CombatRoleFormula {\n Terms?: FormulaTerm[]; // the role's 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 Source?:\n | \"Constant\"\n | \"Stat\"\n | \"RankMultiplier\"\n | \"AllMight\"\n | \"GearFlat\"\n | \"GearPercent\";\n StatID?: string; // used for Stat/GearFlat/GearPercent; empty string for Stat = \"this role's own mapped stat\"\n Value?: number; // used for Constant\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\n}\n```\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)\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 `GlobalStatMultiplier`, `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"
|
|
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"
|
|
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\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 AppleProductID?: string; // empty = not sold via App Store\n GoogleProductID?: string; // empty = not sold via Google Play\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; // AppleProductID or GoogleProductID from the definition\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"
|
|
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"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "purchase-system",
|
|
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 *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
|
+
"references": [
|
|
6
|
+
{
|
|
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"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n}\n```\n\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\nsupposed to call after a successful deduction to compute and grant the\nkickback — but grepping the entire backend turns up **zero** definitions or\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\nevent tied to a follower's purchase — there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\n FollowerIDs?: string[]; // UserIDs of those followers — kept in sync so a follower switching away can be pulled out correctly\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface ReferralInviteRewardState {\n RewardID?: string; // == the MilestoneID key\n IsClaimed?: boolean;\n ClaimedAt?: string | null;\n}\n```\n\n`InviteRewardStates` only contains entries that have actually been claimed —\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\nmilestone systems); a milestone id absent from the map simply means \"not yet\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\n`RequiredProgress`.\n\nA player's referral code **is their own `UserID`** — the module has no\nseparate generated/short code. To let a player share \"their\" code, show them\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\nor endpoint for a display-friendly code.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`):\n - `Linear`: `mult = BaseMultiplier + PerUnit * max(0, progress - Anchor)`.\n - `Tiered` (default): walk `Tiers` sorted by `AtProgress`; below the\n first breakpoint → `BaseMultiplier`; at/above the last → that tier's\n `Multiplier`; between two breakpoints → the lower tier's `Multiplier`\n (`TierMode: \"Step\"`) or a linear interpolation between the two\n (`TierMode: \"Linear\"`).\n - Clamp to `[MinMultiplier, MaxMultiplier]` (`MaxMultiplier <= 0` means\n \"no upper clamp\"); `NaN`/`Infinity` collapses to `1.0`.\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\n `null`, the grant is returned unscaled.\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\n left alone — they're applied later, after scaling, inside\n `ResourceService`.\n - **Rounding**: each scaled amount goes through the platform-wide\n `ModifierService.Apply` with a `Multiply` step, which finishes with\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\nmultiplier)`, never negative, never silently truncated down.\n\nTo preview this on the client before the player claims, call\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\nthe exact same spec/progress/rounding server-side and returns\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\nown copy of this multiplier — it's title-wide, not per-module.\n\n---\n\n## Activation flow — server rules\n\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\nin order:\n\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\n the SDK side before this is even sent).\n2. Trimmed + uppercased. If it equals the caller's own `UserID` (also\n uppercased): `\"Cannot activate your own referral code\"`.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n the caller's id is pulled from their `FollowerIDs`.\n - The new referrer's `FollowersCount` is atomically incremented and the\n caller's id added to `FollowerIDs` (`$addToSet`, so re-adding is a\n no-op).\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\n two as mutual friends (capped by the Social module's friend limit;\n silently skipped if either side is already at the cap).\n - `IsFirstActivation` is `true` only when the caller had **no** previous\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\n When true, `ActivationReward` is granted via\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\n set permanently — a later referrer switch will not re-grant it.\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\n guarding against a concurrent change (matches \"no previous referrer\" or\n \"still the previously-read referrer\"); if that races, the call fails\n with `\"Referral state was modified concurrently. Please retry.\"` and the\n client should just retry.\n\n## Claim flow — server rules\n\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\norder:\n\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\n2. Must exist in `config.Referral.InviteRewards`, else\n `\"Invite reward '{id}' not found in configuration\"`.\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\n `\"Not enough followers. Required: {n}, current: {m}\"`.\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\n5. The resolved grant (see above) is applied atomically with idempotency key\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\n only allows the write when there's no existing claimed state for that\n reward id (protects against a double-claim race the same way step 3/4\n protect against a stale read).\n"
|
|
8
|
+
"content": "# Referral data model — reference\r\n\r\nFull shape of the config (`ReferralDefinitions`) and player state\r\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\r\nshared Core/Milestone progression-multiplier math that scales\r\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\r\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\r\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\r\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\r\nbackend adds later still round-trips. Field names are PascalCase (straight\r\nfrom the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\r\n- [SpendRewardDefinition](#spendrewarddefinition)\r\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\r\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\r\n- [Activation flow — server rules](#activation-flow--server-rules)\r\n- [Claim flow — server rules](#claim-flow--server-rules)\r\n\r\n---\r\n\r\n## Config: ReferralDefinitions\r\n\r\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\r\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\r\n\r\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\r\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\r\n\r\n```ts\r\ninterface ReferralDefinitions {\r\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\r\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\r\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\r\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\r\n}\r\n```\r\n\r\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\r\nthe same shared type used across every module (currencies, items, event\r\ntokens, premium-tier bundles). See the `currency-system` skill for its full\r\nshape if you need it.\r\n\r\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\r\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\r\n\r\n```ts\r\ninterface MilestoneDefinition {\r\n MilestoneID?: string;\r\n DisplayName?: string;\r\n AssetPaths?: Record<string, string>;\r\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\r\n Rewards?: ResourceGrant; // base payout\r\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\r\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\r\n SortOrder?: number;\r\n IsFeatured?: boolean;\r\n}\r\n```\r\n\r\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\r\n`BonusActive`/`SeasonChainID` context (see\r\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\r\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\r\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\r\nthough the type carries them for other modules.\r\n\r\n---\r\n\r\n## SpendRewardDefinition\r\n\r\n```ts\r\ninterface SpendRewardDefinition {\r\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\r\n IsEnabled?: boolean; // default true\r\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\r\n SourceCurrencyID?: string; // currency the follower spends\r\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\r\n}\r\n```\r\n\r\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\r\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\r\nsupposed to call after a successful deduction to compute and grant the\r\nkickback — but grepping the entire backend turns up **zero** definitions or\r\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\r\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\r\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\r\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\r\nevent tied to a follower's purchase — there is nothing to listen for.\r\n\r\n---\r\n\r\n## Player state: UserReferralState\r\n\r\nReturned by `getUserState()` as `{ Referral }`; cached at\r\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\r\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\r\n\r\n```ts\r\ninterface UserReferralState {\r\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\r\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\r\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\r\n FollowerIDs?: string[]; // UserIDs of those followers — kept in sync so a follower switching away can be pulled out correctly\r\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\r\n UpdatedAt?: string; // ISO timestamp of last change\r\n}\r\n\r\ninterface ReferralInviteRewardState {\r\n RewardID?: string; // == the MilestoneID key\r\n IsClaimed?: boolean;\r\n ClaimedAt?: string | null;\r\n}\r\n```\r\n\r\n`InviteRewardStates` only contains entries that have actually been claimed —\r\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\r\nmilestone systems); a milestone id absent from the map simply means \"not yet\r\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\r\n`RequiredProgress`.\r\n\r\nA player's referral code **is their own `UserID`** — the module has no\r\nseparate generated/short code. To let a player share \"their\" code, show them\r\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\r\nor endpoint for a display-friendly code.\r\n\r\n---\r\n\r\n## Invite-reward payout — the Milestone resolver\r\n\r\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\r\nverbatim. The backend runs it through the shared\r\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\r\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\r\ncontext (`Referral.cs`, `ClaimInviteReward`):\r\n\r\n```csharp\r\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\r\n{\r\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\r\n Player = doc,\r\n NowUtc = DateTime.UtcNow,\r\n});\r\n```\r\n\r\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\r\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\r\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\r\nbranches are always skipped for Referral. The **only** overlay that can ever\r\nchange an invite-reward payout is the title-wide progression multiplier:\r\n\r\n1. Read the title's `RewardProgressionMultiplierSpec` from\r\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\r\n also read — configured once per title, not per-module).\r\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\r\n scaling.\r\n3. Otherwise (`RewardProgressionResolver.cs`):\r\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\r\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\r\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\r\n **not** `FollowersCount` — the multiplier's progression axis is\r\n independent of the referral threshold you're claiming against.\r\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\r\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\r\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\r\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\r\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\r\n point the curve is the **identity**, so a player who has not reached the first\r\n tier gets no bonus.\r\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\r\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\r\n real zero. `NaN`/`Infinity` collapses to `1.0`.\r\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\r\n the resolver: a reward multiplier never reduces a reward unless the publisher\r\n says so explicitly.\r\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\r\n `null`, the grant is returned unscaled.\r\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\r\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\r\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\r\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\r\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\r\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\r\n left alone — they're applied later, after scaling, inside\r\n `ResourceService`.\r\n - **Rounding**: each scaled amount goes through the platform-wide\r\n `ModifierService.Apply` with a `Multiply` step, which finishes with\r\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\r\nmultiplier)`, never negative, never silently truncated down.\r\n\r\nTo preview this on the client before the player claims, call\r\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\r\nthe exact same spec/progress/rounding server-side and returns\r\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\r\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\r\nown copy of this multiplier — it's title-wide, not per-module.\r\n\r\n---\r\n\r\n## Activation flow — server rules\r\n\r\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\r\nin order:\r\n\r\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\r\n the SDK side before this is even sent).\r\n2. Trimmed + uppercased. If it equals the caller's own `UserID` (also\r\n uppercased): `\"Cannot activate your own referral code\"`.\r\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\r\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\r\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\r\n6. If the caller is already subscribed to that **same** code:\r\n `\"Referral code already activated\"`.\r\n7. Otherwise the call **succeeds**, whether or not the player had a previous\r\n referrer:\r\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\r\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\r\n the caller's id is pulled from their `FollowerIDs`.\r\n - The new referrer's `FollowersCount` is atomically incremented and the\r\n caller's id added to `FollowerIDs` (`$addToSet`, so re-adding is a\r\n no-op).\r\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\r\n two as mutual friends (capped by the Social module's friend limit;\r\n silently skipped if either side is already at the cap).\r\n - `IsFirstActivation` is `true` only when the caller had **no** previous\r\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\r\n When true, `ActivationReward` is granted via\r\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\r\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\r\n set permanently — a later referrer switch will not re-grant it.\r\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\r\n guarding against a concurrent change (matches \"no previous referrer\" or\r\n \"still the previously-read referrer\"); if that races, the call fails\r\n with `\"Referral state was modified concurrently. Please retry.\"` and the\r\n client should just retry.\r\n\r\n## Claim flow — server rules\r\n\r\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\r\norder:\r\n\r\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\r\n2. Must exist in `config.Referral.InviteRewards`, else\r\n `\"Invite reward '{id}' not found in configuration\"`.\r\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\r\n `\"Not enough followers. Required: {n}, current: {m}\"`.\r\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\r\n5. The resolved grant (see above) is applied atomically with idempotency key\r\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\r\n only allows the write when there's no existing claimed state for that\r\n reward id (protects against a double-claim race the same way step 3/4\r\n protect against a stale read).\r\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Reward data model — reference\n\nFull shape of the config (Definitions) and player state for all four\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\nand the milestone reward multiplier curve. The config side is **strictly\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\nlater still round-trips. The deeper nested shapes shown below as plain\n`interface` blocks in this doc (`DailyCalendarDefinition`,\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\ntypes — most of them are **not individually exported by name** from\n`@idosgames/core`'s public entry point today; don't write `import type {\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\nstyle indexed-access types if you need the standalone name). Per-user state\nobjects beyond the top-level four dictionaries are typed as lenient\npassthrough shapes on the SDK side — the fields documented below are what the\nbackend actually puts on them. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\n- [Tier-reward settings](#tier-reward-settings)\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\n\n---\n\n## Root config: RewardDefinitions\n\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\n`RewardDefinitions.cs`.\n\n```ts\ninterface RewardDefinitions {\n TierRewards?: TierRewardSettings | null;\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\n Claims?: Record<string, ClaimRewardDefinition> | null;\n}\n```\n\nEach of the four dictionaries is an **independent subsystem** — a title can\nuse only some of them; an empty/absent dictionary just means that subsystem is\noff. All four grant rewards through the same `ResourceGrant`, so premium\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\n\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\n\n```ts\ninterface UserRewardState {\n DailyCalendars?: Record<string, UserDailyCalendarState>;\n IdleAccruals?: Record<string, UserIdleAccrualState>;\n Comebacks?: Record<string, UserComebackState>;\n Claims?: Record<string, UserClaimRewardState>;\n}\n```\n\nAn absent entry in any of the four dictionaries means \"player never touched\nthis ID\" — the server treats it as default/zero state, not an error.\n\n---\n\n## Tier-reward settings\n\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\nrewards resolve across _every_ system that has tiers (premium, season, battle\npass, etc.), not just Reward itself. One mode per title.\n\n```ts\ninterface TierRewardSettings {\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\n RewardStackLowerTiers?: boolean; // default: false\n}\n```\n\n- `Additive` — tier rewards are added **on top of** the base reward.\n- `Replace` — tier rewards **fully replace** the base reward.\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\n `false` (default) — only the best matching tier applies.\n\nThis block is read by `ResourceService`, not by Reward's own claim logic\ndirectly — it's here because `RewardDefinitions` is where it's configured.\n\n---\n\n## Daily calendars\n\n### Config: `DailyCalendarDefinition`\n\n```ts\ninterface DailyCalendarDefinition {\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\n MissThresholdMultiplier?: number; // default 2.0\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\n Gate?: SegmentGate; // null/empty = everyone\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface DailyRewardDay {\n DayNumber?: number; // 1-based, unique per calendar\n Rewards?: ResourceGrant;\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserDailyCalendarState`\n\n```ts\ninterface UserDailyCalendarState {\n CalendarID?: string;\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\n}\n```\n\n### Claim eligibility (`ClaimMode`)\n\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\n\n- **`CalendarDayUtc`** (default): a new claim is available once\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\n `\"Daily reward already claimed today for this calendar\"` if the player\n already claimed on today's UTC date. Ignores player timezone.\n- **`SlidingWindow`**: a new claim is available once\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\n\n### Miss detection and `MissBehavior`\n\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\nfirst, before the new day is computed.\n\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\n- **Miss condition** (was the gap too large?):\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\n- **On miss**, `CollectedDays` becomes:\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\n rewards are forfeited, the streak count itself survives).\n - `ResetToStart` — `0` (hard streak reset).\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\n at 0).\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\n\n### Day resolution\n\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\nlooping — only the _day looked up_ wraps, not the counter).\n\nDefault-calendar resolution when `calendarID` is omitted: the server uses\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\nback to the first entry in the dictionary.\n\n---\n\n## Idle accruals\n\n### Config: `IdleAccrualDefinition`\n\n```ts\ninterface IdleAccrualDefinition {\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Rate?: IdleRateConfig;\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\n Requirements?: IdleAccrualRequirements;\n FirstClaimMode?:\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface IdleRateConfig {\n BaseRatePerSecond: number; // flat, unconditional\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\n}\n\ninterface PremiumTierMultiplier {\n MinPremiumTier?: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n Multiplier?: number;\n}\n\ninterface IdleAccrualRequirements {\n MinCharacterLevel: number; // 0 = not checked\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\n Gate?: SegmentGate; // null/empty = not checked\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\n}\n```\n\n### State: `UserIdleAccrualState`\n\n```ts\ninterface UserIdleAccrualState {\n AccrualID?: string;\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\n}\n```\n\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\n\n```\nrawRate = BaseRatePerSecond\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\n\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\n MinPremiumTier <= player's MaxActiveTier (and matching\n RequiredPremiumID if set); 1.0 if none match or premium is null\n — multipliers never stack.\n\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\n```\n\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\n\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\nsite yet. The config fields exist and round-trip, but don't promise \"gear\nboosts idle income\" in product copy until this is verified live via\n`AppliedRatePerSecond` in a real claim response.\n\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\n\n```\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\n = otherwise, resolved by FirstClaimMode:\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\n\nelapsedSeconds = max(0, now - effectiveStart) in seconds\naccruedSeconds = MaxAccumulationSeconds > 0\n ? min(elapsedSeconds, MaxAccumulationSeconds)\n : elapsedSeconds\n```\n\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\nTry again in {n}s\"`.\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\n zero-payout finalize — sets `LastCollectAt = now`, returns\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\n unscaled and are applied afterward by `ResourceService` as usual.\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\n\n### Requirements gate (checked every claim, not persisted)\n\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\n\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\nhigher premium tier\"`.\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\n'{id}' level {n} is below required {m}\"`.\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\n `\"Required item '{id}' is not in inventory\"`.\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\n'{charID}'\"`.\n\nFailing a requirement does **not** move `LastCollectAt` — once the\nrequirement is met again, the previously-accrued time (up to the cap) is still\ncollectible.\n\n---\n\n## Comeback rewards\n\n### Config: `ComebackRewardDefinition`\n\n```ts\ninterface ComebackRewardDefinition {\n ComebackID?: string; // key in Comebacks; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tiers?: ComebackTier[];\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\n TrackPresenceOnRead?: boolean; // default true\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ComebackTier {\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\n Rewards?: ResourceGrant;\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserComebackState`\n\n```ts\ninterface UserComebackState {\n ComebackID?: string;\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\n LastClaimAt: string; // ISO; MinValue = never claimed\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\n}\n```\n\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\n\nRuns on **every** claim call for this comeback, and also on\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\n\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\n stop — no absence to evaluate yet.\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\n both set): if `ClaimWindowSeconds > 0` and\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\n means it never expires on its own.)\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\n ties broken by taking the higher threshold). If a tier matches AND the\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\n\nThe tier is deliberately locked at the **moment of return**, not at claim\ntime — this stops a player from delaying the claim to try to \"grow into\" a\nricher tier.\n\n### Claim (`RewardV2.ClaimComebackReward`)\n\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\ntimestamp, so a stale pending anchor from a concurrent request can't be\ndouble-spent.\n\n---\n\n## Claim rewards\n\n### Config: `ClaimRewardDefinition`\n\n```ts\ninterface ClaimRewardDefinition {\n ClaimID?: string; // key in Claims; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\n Rewards?: ResourceGrant;\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ClaimLimitOverride {\n MinPremiumTier: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\n WindowSeconds?: number | null;\n TotalClaimLimit?: number | null;\n}\n```\n\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\n\n### State: `UserClaimRewardState`\n\n```ts\ninterface UserClaimRewardState {\n ClaimID?: string;\n TotalClaims: number; // monotonically increasing; never resets\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\n LastClaimAt: string; // ISO; MinValue = never claimed\n}\n```\n\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\n\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\nthe player has an active premium tier, the **one** override with the highest\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\noverrides never stack. Each of that override's four fields is applied only if\nnon-null; a null field falls back to the base `Limits` value, not to \"no\nlimit.\"\n\n### Claim validation order (`RewardV2.PrepareClaimReward`)\n\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\nclaimable by client (server-only)\"`), and `Rewards` is configured.\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\n4. Resolve effective limits (base + best override).\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\n `\"Total claim limit reached ({have}/{limit})\"`.\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\n `\"Reward is on cooldown. Try again in {n}s\"`.\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\n{window}s)\"`.\n8. On success, `now` is appended to the window list, then the list is\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\n first). `TotalClaims` increments by 1 regardless of window/cooldown\n settings.\n\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\nclient `claimReward` call against one is always rejected.\n\n### Batch claiming (backend-only today)\n\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\nindependently (invalid/ineligible ids are filtered out and reported before any\ncharge), then applies the merged valid set as a single atomic operation with\none combined `Resources` payload attached to the first successful result\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\nso this path is not reachable from the TS client yet.\n\n---\n\n## Milestone reward multiplier\n\n`RewardDefinitions.MilestoneRewardMultiplier` is a\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\nfour subsystems; it's a title-wide overlay that other milestone-bearing\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\n_last_ overlay in their reward-resolution chain.\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: ProgressionSource; // metric the multiplier is driven by\n SourceKey?: string; // disambiguator when Source needs one\n CurveType?: \"Tiered\" | \"Linear\"; // default Tiered\n Tiers?: { AtProgress?: number; Multiplier?: number }[]; // used when CurveType = Tiered\n TierMode?: \"Step\" | \"Linear\"; // interpolation BETWEEN tier breakpoints; default Step\n BaseMultiplier?: number; // floor value below the first tier / Linear curve's base\n PerUnit?: number; // used when CurveType = Linear\n Anchor?: number; // used when CurveType = Linear\n MinMultiplier?: number; // hard floor after evaluation\n MaxMultiplier?: number; // hard ceiling after evaluation; <= 0 = no ceiling\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\n`ProgressionSource` values (from `MilestoneModels.ts` /\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\n\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\n\n```\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\n\nif CurveType == \"Linear\":\n raw = BaseMultiplier + PerUnit * max(0, progress - Anchor)\n\nelse (CurveType == \"Tiered\", default):\n tiers sorted ascending by AtProgress\n if no tiers: raw = BaseMultiplier\n else if progress < tiers[0].AtProgress: raw = BaseMultiplier\n else if progress >= tiers[last].AtProgress: raw = tiers[last].Multiplier\n else: find bracketing tiers (lo, hi) where progress falls between AtProgress values\n if TierMode == \"Linear\": raw = lo.Multiplier + frac * (hi.Multiplier - lo.Multiplier)\n where frac = (progress - lo.AtProgress) / (hi.AtProgress - lo.AtProgress)\n else (\"Step\", default): raw = lo.Multiplier\n\nfinal = clamp(raw): NaN/Infinity -> 1.0; then floor at MinMultiplier;\n then ceiling at MaxMultiplier only if MaxMultiplier > 0\n```\n\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\nlive `Multiplier`, the raw `Progress` value read from the player's current\nprogression state, and echoes of `Source`/`SourceKey`.\n\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\n\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\nMatching for items is by `ItemID`; for currencies/event-tokens, by\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\nuntouched by this step — they're applied afterward, on top of the\nalready-scaled `Standard` bundle, by `ResourceService`.\n\n---\n\n## Shared plumbing\n\nThese blocks are reused by all four subsystems (and the rest of the\nplatform) — full details live in their own modules; summarized here only as\nthey affect Reward.\n\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\n used by `Gate` fields on `DailyCalendarDefinition`,\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\n a failing gate always surfaces as `reason: \"server\"` with a\n \"locked behind a higher premium tier\"-style message — there is no\n client-visible breakdown of _which_ gate condition failed.\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\n shared type but ignored by `RewardV2`.\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\n subsystem's `Rewards` field and every claim response's `data.Resources` use\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\n amount by the time it reaches the client.\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\n one of the four definition types follow the same rule:\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\n both may be absent for \"no bound.\"\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\n the three id-taking methods (not `claimDailyReward`'s optional\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\n trip for the common typo case.\n"
|
|
8
|
+
"content": "# Reward data model — reference\r\n\r\nFull shape of the config (Definitions) and player state for all four\r\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\r\nand the milestone reward multiplier curve. The config side is **strictly\r\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\r\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\r\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\r\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\r\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\r\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\r\nlater still round-trips. The deeper nested shapes shown below as plain\r\n`interface` blocks in this doc (`DailyCalendarDefinition`,\r\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\r\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\r\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\r\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\r\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\r\ntypes — most of them are **not individually exported by name** from\r\n`@idosgames/core`'s public entry point today; don't write `import type {\r\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\r\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\r\nstyle indexed-access types if you need the standalone name). Per-user state\r\nobjects beyond the top-level four dictionaries are typed as lenient\r\npassthrough shapes on the SDK side — the fields documented below are what the\r\nbackend actually puts on them. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\n## Contents\r\n\r\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\r\n- [Tier-reward settings](#tier-reward-settings)\r\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\r\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\r\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\r\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\r\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\r\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\r\n\r\n---\r\n\r\n## Root config: RewardDefinitions\r\n\r\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\r\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\r\n`RewardDefinitions.cs`.\r\n\r\n```ts\r\ninterface RewardDefinitions {\r\n TierRewards?: TierRewardSettings | null;\r\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\r\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\r\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\r\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\r\n Claims?: Record<string, ClaimRewardDefinition> | null;\r\n}\r\n```\r\n\r\nEach of the four dictionaries is an **independent subsystem** — a title can\r\nuse only some of them; an empty/absent dictionary just means that subsystem is\r\noff. All four grant rewards through the same `ResourceGrant`, so premium\r\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\r\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\r\n\r\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\r\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\r\n\r\n```ts\r\ninterface UserRewardState {\r\n DailyCalendars?: Record<string, UserDailyCalendarState>;\r\n IdleAccruals?: Record<string, UserIdleAccrualState>;\r\n Comebacks?: Record<string, UserComebackState>;\r\n Claims?: Record<string, UserClaimRewardState>;\r\n}\r\n```\r\n\r\nAn absent entry in any of the four dictionaries means \"player never touched\r\nthis ID\" — the server treats it as default/zero state, not an error.\r\n\r\n---\r\n\r\n## Tier-reward settings\r\n\r\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\r\nrewards resolve across _every_ system that has tiers (premium, season, battle\r\npass, etc.), not just Reward itself. One mode per title.\r\n\r\n```ts\r\ninterface TierRewardSettings {\r\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\r\n RewardStackLowerTiers?: boolean; // default: false\r\n}\r\n```\r\n\r\n- `Additive` — tier rewards are added **on top of** the base reward.\r\n- `Replace` — tier rewards **fully replace** the base reward.\r\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\r\n `false` (default) — only the best matching tier applies.\r\n\r\nThis block is read by `ResourceService`, not by Reward's own claim logic\r\ndirectly — it's here because `RewardDefinitions` is where it's configured.\r\n\r\n---\r\n\r\n## Daily calendars\r\n\r\n### Config: `DailyCalendarDefinition`\r\n\r\n```ts\r\ninterface DailyCalendarDefinition {\r\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\r\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\r\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\r\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\r\n MissThresholdMultiplier?: number; // default 2.0\r\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\r\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\r\n Gate?: SegmentGate; // null/empty = everyone\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface DailyRewardDay {\r\n DayNumber?: number; // 1-based, unique per calendar\r\n Rewards?: ResourceGrant;\r\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserDailyCalendarState`\r\n\r\n```ts\r\ninterface UserDailyCalendarState {\r\n CalendarID?: string;\r\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\r\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\r\n}\r\n```\r\n\r\n### Claim eligibility (`ClaimMode`)\r\n\r\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\r\n\r\n- **`CalendarDayUtc`** (default): a new claim is available once\r\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\r\n `\"Daily reward already claimed today for this calendar\"` if the player\r\n already claimed on today's UTC date. Ignores player timezone.\r\n- **`SlidingWindow`**: a new claim is available once\r\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\r\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\r\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\r\n\r\n### Miss detection and `MissBehavior`\r\n\r\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\r\nfirst, before the new day is computed.\r\n\r\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\r\n- **Miss condition** (was the gap too large?):\r\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\r\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\r\n- **On miss**, `CollectedDays` becomes:\r\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\r\n rewards are forfeited, the streak count itself survives).\r\n - `ResetToStart` — `0` (hard streak reset).\r\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\r\n at 0).\r\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\r\n\r\n### Day resolution\r\n\r\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\r\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\r\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\r\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\r\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\r\nlooping — only the _day looked up_ wraps, not the counter).\r\n\r\nDefault-calendar resolution when `calendarID` is omitted: the server uses\r\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\r\nback to the first entry in the dictionary.\r\n\r\n---\r\n\r\n## Idle accruals\r\n\r\n### Config: `IdleAccrualDefinition`\r\n\r\n```ts\r\ninterface IdleAccrualDefinition {\r\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Rate?: IdleRateConfig;\r\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\r\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\r\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\r\n Requirements?: IdleAccrualRequirements;\r\n FirstClaimMode?:\r\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface IdleRateConfig {\r\n BaseRatePerSecond: number; // flat, unconditional\r\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\r\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\r\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\r\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\r\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\r\n}\r\n\r\ninterface PremiumTierMultiplier {\r\n MinPremiumTier?: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n Multiplier?: number;\r\n}\r\n\r\ninterface IdleAccrualRequirements {\r\n MinCharacterLevel: number; // 0 = not checked\r\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\r\n Gate?: SegmentGate; // null/empty = not checked\r\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\r\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\r\n}\r\n```\r\n\r\n### State: `UserIdleAccrualState`\r\n\r\n```ts\r\ninterface UserIdleAccrualState {\r\n AccrualID?: string;\r\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\r\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\r\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\r\n}\r\n```\r\n\r\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\r\n\r\n```\r\nrawRate = BaseRatePerSecond\r\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\r\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\r\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\r\n\r\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\r\n MinPremiumTier <= player's MaxActiveTier (and matching\r\n RequiredPremiumID if set); 1.0 if none match or premium is null\r\n — multipliers never stack.\r\n\r\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\r\n```\r\n\r\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\r\n\r\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\r\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\r\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\r\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\r\nsite yet. The config fields exist and round-trip, but don't promise \"gear\r\nboosts idle income\" in product copy until this is verified live via\r\n`AppliedRatePerSecond` in a real claim response.\r\n\r\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\r\n\r\n```\r\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\r\n = otherwise, resolved by FirstClaimMode:\r\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\r\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\r\n\r\nelapsedSeconds = max(0, now - effectiveStart) in seconds\r\naccruedSeconds = MaxAccumulationSeconds > 0\r\n ? min(elapsedSeconds, MaxAccumulationSeconds)\r\n : elapsedSeconds\r\n```\r\n\r\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\r\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\r\nTry again in {n}s\"`.\r\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\r\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\r\n zero-payout finalize — sets `LastCollectAt = now`, returns\r\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\r\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\r\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\r\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\r\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\r\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\r\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\r\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\r\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\r\n unscaled and are applied afterward by `ResourceService` as usual.\r\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\r\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\r\n\r\n### Requirements gate (checked every claim, not persisted)\r\n\r\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\r\n\r\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\r\nhigher premium tier\"`.\r\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\r\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\r\n'{id}' level {n} is below required {m}\"`.\r\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\r\n `\"Required item '{id}' is not in inventory\"`.\r\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\r\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\r\n'{charID}'\"`.\r\n\r\nFailing a requirement does **not** move `LastCollectAt` — once the\r\nrequirement is met again, the previously-accrued time (up to the cap) is still\r\ncollectible.\r\n\r\n---\r\n\r\n## Comeback rewards\r\n\r\n### Config: `ComebackRewardDefinition`\r\n\r\n```ts\r\ninterface ComebackRewardDefinition {\r\n ComebackID?: string; // key in Comebacks; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Tiers?: ComebackTier[];\r\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\r\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\r\n TrackPresenceOnRead?: boolean; // default true\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ComebackTier {\r\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\r\n Rewards?: ResourceGrant;\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserComebackState`\r\n\r\n```ts\r\ninterface UserComebackState {\r\n ComebackID?: string;\r\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\r\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\r\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\r\n}\r\n```\r\n\r\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\r\n\r\nRuns on **every** claim call for this comeback, and also on\r\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\r\n\r\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\r\n stop — no absence to evaluate yet.\r\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\r\n both set): if `ClaimWindowSeconds > 0` and\r\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\r\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\r\n means it never expires on its own.)\r\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\r\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\r\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\r\n ties broken by taking the higher threshold). If a tier matches AND the\r\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\r\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\r\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\r\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\r\n\r\nThe tier is deliberately locked at the **moment of return**, not at claim\r\ntime — this stops a player from delaying the claim to try to \"grow into\" a\r\nricher tier.\r\n\r\n### Claim (`RewardV2.ClaimComebackReward`)\r\n\r\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\r\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\r\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\r\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\r\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\r\ntimestamp, so a stale pending anchor from a concurrent request can't be\r\ndouble-spent.\r\n\r\n---\r\n\r\n## Claim rewards\r\n\r\n### Config: `ClaimRewardDefinition`\r\n\r\n```ts\r\ninterface ClaimRewardDefinition {\r\n ClaimID?: string; // key in Claims; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\r\n Rewards?: ResourceGrant;\r\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\r\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ClaimLimitOverride {\r\n MinPremiumTier: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\r\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\r\n WindowSeconds?: number | null;\r\n TotalClaimLimit?: number | null;\r\n}\r\n```\r\n\r\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\r\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\r\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\r\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\r\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\r\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\r\n\r\n### State: `UserClaimRewardState`\r\n\r\n```ts\r\ninterface UserClaimRewardState {\r\n ClaimID?: string;\r\n TotalClaims: number; // monotonically increasing; never resets\r\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n}\r\n```\r\n\r\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\r\n\r\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\r\nthe player has an active premium tier, the **one** override with the highest\r\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\r\noverrides never stack. Each of that override's four fields is applied only if\r\nnon-null; a null field falls back to the base `Limits` value, not to \"no\r\nlimit.\"\r\n\r\n### Claim validation order (`RewardV2.PrepareClaimReward`)\r\n\r\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\r\nclaimable by client (server-only)\"`), and `Rewards` is configured.\r\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\r\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\r\n4. Resolve effective limits (base + best override).\r\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\r\n `\"Total claim limit reached ({have}/{limit})\"`.\r\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\r\n `\"Reward is on cooldown. Try again in {n}s\"`.\r\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\r\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\r\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\r\n{window}s)\"`.\r\n8. On success, `now` is appended to the window list, then the list is\r\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\r\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\r\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\r\n first). `TotalClaims` increments by 1 regardless of window/cooldown\r\n settings.\r\n\r\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\r\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\r\nclient `claimReward` call against one is always rejected.\r\n\r\n### Batch claiming (backend-only today)\r\n\r\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\r\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\r\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\r\nindependently (invalid/ineligible ids are filtered out and reported before any\r\ncharge), then applies the merged valid set as a single atomic operation with\r\none combined `Resources` payload attached to the first successful result\r\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\r\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\r\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\r\nso this path is not reachable from the TS client yet.\r\n\r\n---\r\n\r\n## Milestone reward multiplier\r\n\r\n`RewardDefinitions.MilestoneRewardMultiplier` is a\r\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\r\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\r\nfour subsystems; it's a title-wide overlay that other milestone-bearing\r\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\r\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\r\n_last_ overlay in their reward-resolution chain.\r\n\r\n```ts\r\ninterface RewardProgressionMultiplierSpec {\r\n Source?: ProgressionSource; // metric the multiplier is driven by\r\n SourceKey?: string; // disambiguator when Source needs one\r\n Curve?: ScalarCurveSpec; // the curve; base 1 unless Base is set. Empty = no scaling\r\n Anchor?: number; // progress value the curve starts counting from; empty = 0\r\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\r\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\r\n}\r\n```\r\n\r\n`ProgressionSource` values (from `MilestoneModels.ts` /\r\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\r\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\r\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\r\n\r\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\r\n\r\nThe eight fields that used to describe the curve here (`CurveType`, `Tiers`, `TierMode`,\r\n`BaseMultiplier`, `PerUnit`, `MinMultiplier`, `MaxMultiplier`) collapsed into one shared\r\n`ScalarCurveSpec`:\r\n\r\n| Old shape | Now |\r\n| --- | --- |\r\n| `CurveType: \"Tiered\"` + `Tiers` | `Shape: \"Table\"` with `Points: [{ AtStep, Value }]` |\r\n| `TierMode: \"Step\" \\| \"Linear\"` | `Interpolation: \"Step\" \\| \"Linear\"` (also `\"Geometric\"`) |\r\n| `CurveType: \"Linear\"` + `PerUnit` | `Shape: \"PerStepRate\"` (share of the base per unit) |\r\n| `BaseMultiplier` | `Base` (empty = 1, i.e. a multiplier that changes nothing) |\r\n| `MinMultiplier` / `MaxMultiplier` | `MinResult` / `MaxResult` — **empty means NO bound**, and `0` now means a real zero |\r\n\r\n```\r\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\r\n\r\nraw = evaluateCurve(spec.Curve, base = 1.0, step = progress, firstStep = spec.Anchor ?? 0)\r\nfinal = NaN/Infinity -> 1.0\r\n```\r\n\r\n⚠ **The floor \"a reward multiplier never REDUCES a reward\" is no longer a config field.**\r\nIt is a domain rule of the resolver: when the publisher sets no `MinResult`, the result is\r\nfloored at `1.0`. Deliberate reduction is expressed by a curve that DOES set `MinResult`\r\nbelow 1 — so it can only happen on purpose, never by a stray zero.\r\n\r\n⚠ **Before the first table point a curve is the IDENTITY, not the first point's value.**\r\nA player who has not reached the first tier gets no bonus at all.\r\n\r\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\r\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\r\nlive `Multiplier`, the raw `Progress` value read from the player's current\r\nprogression state, and echoes of `Source`/`SourceKey`.\r\n\r\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\r\n\r\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\r\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\r\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\r\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\r\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\r\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\r\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\r\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\r\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\r\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\r\nMatching for items is by `ItemID`; for currencies/event-tokens, by\r\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\r\nuntouched by this step — they're applied afterward, on top of the\r\nalready-scaled `Standard` bundle, by `ResourceService`.\r\n\r\n---\r\n\r\n## Shared plumbing\r\n\r\nThese blocks are reused by all four subsystems (and the rest of the\r\nplatform) — full details live in their own modules; summarized here only as\r\nthey affect Reward.\r\n\r\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\r\n used by `Gate` fields on `DailyCalendarDefinition`,\r\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\r\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\r\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\r\n a failing gate always surfaces as `reason: \"server\"` with a\r\n \"locked behind a higher premium tier\"-style message — there is no\r\n client-visible breakdown of _which_ gate condition failed.\r\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\r\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\r\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\r\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\r\n shared type but ignored by `RewardV2`.\r\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\r\n subsystem's `Rewards` field and every claim response's `data.Resources` use\r\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\r\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\r\n amount by the time it reaches the client.\r\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\r\n one of the four definition types follow the same rule:\r\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\r\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\r\n both may be absent for \"no bound.\"\r\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\r\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\r\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\r\n the three id-taking methods (not `claimDailyReward`'s optional\r\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\r\n trip for the common typo case.\r\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|