@idosgames/mcp 0.1.4 → 0.1.6

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.
@@ -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 Cost?: ResourceConsume; // flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount)\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\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"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "premium-system",
3
3
  "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.",
4
- "content": "---\nname: premium-system\ndescription: >-\n Build a premium / subscription / VIP-tier system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.premium (PremiumService): load\n premium tier definitions, load the player's active subscriptions, activate a\n free trial, purchase a premium tier with virtual/item cost, or complete a\n real-money IAP subscription purchase (App Store / Google Play receipt\n validation). This is the player's subscription/IAP tier that other modules\n (Store cost discounts via ResourceConsume.PremiumDiscounts, reward/lootbox\n grant multipliers via ResourceGrant.PremiumTiers, segment gates via\n SegmentGate.MinPremiumTier) read to unlock perks. Use whenever the user wants\n a subscription/VIP/battle-pass-tier paywall, IAP receipt validation, trial\n flows, or touches client.premium, PremiumService, PremiumDefinition,\n MaxActiveTier, or premium discounts/multipliers — even if they don't name the\n module explicitly.\n---\n\n# Premium system (iDosGames TS SDK)\n\nThe Premium module is the title's **subscription / IAP tier** system: players\nactivate a trial or purchase a premium tier (with virtual currency, or —\nintended — real money via App Store / Google Play), and other modules read the\nplayer's active tier to unlock discounts, bonus multipliers, and gated\ncontent. Everything is **server-authoritative**: the client asks the backend\nto activate/purchase, the backend validates the transaction (cost, trial\neligibility, and — for real money — the receipt with Apple/Google), and the\nSDK mirrors the confirmed result into a local cache your UI reads. You never\nmutate premium state yourself — you call a method, check the result, and\nrender from the cache.\n\nThis skill is for **using** the production `PremiumService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(already trialed, receipt invalid, insufficient funds) — surface the error,\ndon't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n premium tiers: `Tier` number, `DurationDays`, `TrialDurationDays`, price\n options (virtual/item cost per option), App/Google product IDs, and\n free-form `Benefits` (display-only). Fetched with `getDefinitions()`.\n2. **User premium state** (state, per player) — this player's subscriptions\n (`Subscriptions`, keyed by `PremiumID`, including expired ones — they're\n never deleted), which trials they've already used (`ActivatedTrialIDs`,\n permanent), and their current best tier (`MaxActiveTier`). Fetched with\n `getUserState()`.\n\nA premium tier is identified by a string `PremiumID`. `Tier` is a plain number\n(higher = better) — it's what other modules compare against\n(`SegmentGate.MinPremiumTier`, `PremiumTierBundle.MinPremiumTier`) to decide\nwhether a perk applies; some gates instead pin an exact `RequiredPremiumID`,\nin which case the tier number is ignored. `MaxActiveTier` on the user state is\nthe number to read when you just need \"what's the player's current tier\"\nwithout walking `Subscriptions` yourself — the backend recalculates it from\nscratch (max `Tier` among non-expired subscriptions) on every read and write,\nso it self-heals even if a subscription lapsed since the last call.\n\nFor the full field-by-field shape, the trial-eligibility rules, tier\nresolution, and the purchase/renewal math, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (renewal countdowns, trial\neligibility, cost previews) off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst premium = client.premium; // the PremiumService\n```\n\nEvery premium method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing `TransactionID`/`ProductID`/`ReceiptData`),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window, default 600 ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Trial already used.\"`,\n`\"Subscription already active.\"`, `\"PriceOption not found\"`, insufficient\nfunds).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------- |\n| `getDefinitions()` | Load the title's premium tier catalog (config). | `PremiumDefinitionsResponse` (`Premium`) |\n| `getUserState()` | Load this player's subscriptions/trials/tier (state). | `PremiumStateResponse` (`Premium`) |\n| `activateTrial(premiumID, transactionID)` | Start a free trial of a tier (one-time per `PremiumID`, forever). | `PremiumPurchaseResponse` |\n| `purchaseItemOrCurrency(premiumID, transactionID, selectedOptionID?, count?)` | Buy/renew a tier with a virtual/item price option (default option `\"Default\"`, count 1). | `PremiumPurchaseResponse` |\n| `purchaseRealMoney(premiumID, transactionID, store, productID, receiptData, purchaseToken?, packageName?, appStoreEnvironment?)` | Send a real-money IAP receipt for validation. **Not implemented on the backend yet** — see Gotchas. | `PremiumPurchaseResponse` |\n\n`store` is `\"Apple\"` or `\"Google\"` (`StoreType`). `purchaseToken` is\nGoogle-specific (Play Billing purchase token); `packageName` and\n`appStoreEnvironment` are optional extra context some validators need. Every\npurchase/trial call requires a caller-supplied `transactionID` — treat it as\nthis attempt's client-side transaction id (distinct per attempt; the SDK also\nderives an internal idempotency key from it, and the backend independently\ntreats a repeated `transactionID` on an existing subscription as a no-charge\nreplay rather than a new purchase).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. The purchase methods also\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\n`packages/core/src/models/_shared/ResourceModels.ts`) to cached balances when\npresent; `activateTrial` always returns an empty `Resources` since a trial\ndoesn't move currency. Read updated tier/balances straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { PremiumDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<PremiumDefinitions>(\"Premium\");\nconst tier = defs?.Definitions?.[\"vip_gold\"];\ntier?.Tier; // numeric tier level\ntier?.PriceOptions; // { optionID: { RequiredResources: ResourceConsume } }\ntier?.Benefits; // free-form { key: stringified value } for display\n\n// User state (only present after getUserState() or a purchase/trial):\nconst p = client.data.user.state?.Premium;\np?.MaxActiveTier; // current best tier (number), self-healing on every fetch\np?.Subscriptions?.[\"vip_gold\"]?.ExpirationDate; // check this, not just key existence\np?.ActivatedTrialIDs; // trials already used, ever — don't offer them again\n```\n\n`applyPremium` fully **replaces** the cached `Premium` object on every write\n(`packages/core/src/cache/UserData.ts:672`) — it's not a deep merge, so a\n`getUserState()`/purchase/trial response always reflects the complete,\nauthoritative state.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `premium:definitionsLoaded` → `PremiumDefinitionsResponse`\n- `premium:stateLoaded` → `PremiumStateResponse`\n- `premium:trialActivated` → `PremiumPurchaseResponse`\n- `premium:purchaseCompleted` → `PremiumPurchaseResponse`\n- `premium:realMoneyPurchaseCompleted` → `PremiumPurchaseResponse`\n\nThe coarse `user:premiumUpdated` (and `user:anyUpdated`) also fire on any\npremium cache write — handy for a \"re-render everything\" hook, and useful for\nany other screen that displays a tier-gated perk.\n\n```ts\nconst off = client.on(\"premium:purchaseCompleted\", (r) => {\n console.log(`Now on ${r.Subscription?.PremiumID}, tier state refreshed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show tiers and the player's current status\n\n```ts\nawait client.premium.getDefinitions();\nawait client.premium.getUserState();\n\nconst defs = client.data.config.getSection<PremiumDefinitions>(\"Premium\");\nconst state = client.data.user.state?.Premium;\n\nfor (const [premiumID, tier] of Object.entries(defs?.Definitions ?? {})) {\n const active = state?.Subscriptions?.[premiumID];\n const isActive =\n !!active?.ExpirationDate && new Date(active.ExpirationDate) > new Date();\n const canTrial =\n (tier.TrialDurationDays ?? 0) > 0 &&\n !state?.ActivatedTrialIDs?.includes(premiumID);\n // isActive drives \"renews on\"/\"expired\" copy;\n // canTrial drives whether to show a \"Start free trial\" button.\n}\n```\n\n### Activate a free trial\n\n```ts\nconst res = await client.premium.activateTrial(\"vip_gold\", crypto.randomUUID());\nif (!res.ok) return showError(res.error); // e.g. \"Trial already used.\"\nres.data.Subscription?.ExpirationDate; // when the trial ends\n```\n\nOnly tiers with `TrialDurationDays > 0` support this — otherwise the backend\nrejects with `\"Trial is not available for this premium.\"` A trial also can't\nbe started on top of an already-active subscription to the same tier\n(`\"Subscription already active.\"`).\n\n### Purchase a tier with virtual currency\n\n```ts\nconst res = await client.premium.purchaseItemOrCurrency(\n \"vip_gold\",\n crypto.randomUUID(),\n \"Default\",\n 1,\n);\nif (!res.ok) return showError(res.error); // e.g. \"PriceOption not found\", insufficient funds\n// cache now has the new/renewed subscription + debited balances.\n```\n\nIf the player already has this exact tier active, the purchase **extends**\nits `ExpirationDate` rather than starting a fresh countdown or stacking a\nsecond entry — see\n[the renewal rule](references/data-model.md#purchase-with-virtual-currency--items).\nThe chosen `selectedOptionID` must exist in that tier's `PriceOptions`, and\nthat option must carry a non-empty virtual cost — an option with no\n`RequiredResources` is reserved for the real-money flow and this call rejects\nit.\n\n### Complete a real-money purchase (App Store / Google Play) — not yet backed\n\n```ts\n// After your platform IAP SDK confirms the purchase and hands you a receipt:\nconst res = await client.premium.purchaseRealMoney(\n \"vip_gold\",\n crypto.randomUUID(),\n \"Apple\",\n \"com.yourgame.vip_gold_monthly\",\n receiptData, // base64 receipt / signed transaction payload\n);\nif (!res.ok) return showError(res.error);\n```\n\nThe method, request shape, and events are implemented client-side, but the\ncurrent backend has **no handler** for this action — calling it returns\n`reason: \"server\", error: \"Action not implemented\"` (see Gotchas below and\n[the reference](references/data-model.md#real-money-iap-purchase--current-backend-status)\nfor exactly why). Do not wire this into a shipping purchase button yet; treat\nit as a documented but currently-nonfunctional call.\n\n### Read the tier elsewhere (discounts/multipliers)\n\nOther modules don't expose a \"premium\" parameter — they read the cached tier\nthemselves server-side when computing `ResourceConsume.PremiumDiscounts` /\n`ResourceGrant.PremiumTiers` / `SegmentGate.MinPremiumTier`. Client-side, use\n`MaxActiveTier` only to preview/gray-out UI (e.g. \"Requires VIP Gold+\" on a\nstore offer or reward tier); the actual discount/bonus is applied by the\nbackend and arrives in that call's own `Resources`.\n\n```ts\nconst myTier = client.data.user.state?.Premium?.MaxActiveTier ?? 0;\nconst locked = (bundle.MinPremiumTier ?? 0) > myTier;\n```\n\n## Gotchas\n\n- **`purchaseRealMoney` is not implemented on the backend.** The v2\n `Premium.cs` HTTP handler's action switch only has cases for\n `GetDefinitions`, `GetUserState`, `ActivateTrial`, and\n `PurchaseWithResources` — `PurchaseRealMoney` falls through to\n `default: \"Action not implemented\"`\n (`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:52-69`). Real-money receipt\n validation exists in the backend only under the legacy **v1** surface\n (`ValidateIAP.cs`/`ValidateIAPSubscription.cs`), which is a separate\n endpoint family not reachable through `client.premium`. Don't build a\n shipping IAP-subscription flow on this call until the backend adds a real\n handler.\n- **`ActivatedTrialIDs` is permanent and never cleared.** A trial is one-time\n per `PremiumID` per account for the lifetime of the account — cancelling,\n expiring, or unsubscribing doesn't remove the id. Check it client-side\n before showing a trial CTA rather than relying only on the server\n rejection (`\"Trial already used.\"`).\n- **An entry in `Subscriptions` isn't necessarily active.** Expired\n subscriptions are kept, not deleted (they support renewal-on-top-of-lapse\n math and history). Always check `ExpirationDate`, or just trust\n `MaxActiveTier`, which already excludes expired/unknown tiers.\n- **Tiers take the max, they don't stack.** Holding two active subscriptions\n at once doesn't add their `Tier`s — `MaxActiveTier` is simply the highest\n `Tier` among currently-active subscriptions.\n- **Buying an already-active tier extends it, it doesn't restart it.** The\n new duration is added on top of the existing `ExpirationDate`; buying tier\n X again while X is still active is a renewal, not a discard-and-replace.\n- **Guard against double-submit.** Each call needs a caller-supplied\n `transactionID`; reusing the same one for a retry is fine (that's what it's\n for — both the trial and purchase endpoints detect a matching\n `TransactionID` on the existing subscription and return the current state\n with no new charge), but a double-clicked \"Subscribe\" with two _different_\n generated ids is two real attempts. Disable the control while a call is in\n flight. Firing the same endpoint again within the client throttle window\n (default 600 ms) is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.\n- **`Resources` is nullish only in shape, not in practice.** Trial responses\n and idempotent-replay responses always come back with an explicit empty\n `ResourceOperation` (never `null`) — but the type is still nullish, so\n guard before reading into it anyway.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the exact tier-resolution algorithm, trial eligibility rules,\nthe purchase/renewal math, verbatim backend rejection strings, the current\nreal-money-purchase gap, and how other modules' gate/discount/multiplier\ntypes reference Premium's `Tier`/`MaxActiveTier`. Read it when building\nconfig-driven UI (renewal countdowns, trial eligibility, cost previews) or\nwhen an error message points at a rule you need to understand.\n",
4
+ "content": "---\nname: premium-system\ndescription: >-\n Build a premium / subscription / VIP-tier system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.premium (PremiumService): load\n premium tier definitions, load the player's active subscriptions, activate a\n free trial, purchase a premium tier with virtual/item cost, or complete a\n real-money IAP subscription purchase (App Store / Google Play receipt\n validation). This is the player's subscription/IAP tier that other modules\n (Store cost discounts via ResourceConsume.PremiumDiscounts, reward/lootbox\n grant multipliers via ResourceGrant.PremiumTiers, segment gates via\n SegmentGate.MinPremiumTier) read to unlock perks. Use whenever the user wants\n a subscription/VIP/battle-pass-tier paywall, IAP receipt validation, trial\n flows, or touches client.premium, PremiumService, PremiumDefinition,\n MaxActiveTier, or premium discounts/multipliers — even if they don't name the\n module explicitly.\n---\n\n# Premium system (iDosGames TS SDK)\n\nThe Premium module is the title's **subscription / IAP tier** system: players\nactivate a trial or purchase a premium tier (with virtual currency, or —\nintended — real money via App Store / Google Play), and other modules read the\nplayer's active tier to unlock discounts, bonus multipliers, and gated\ncontent. Everything is **server-authoritative**: the client asks the backend\nto activate/purchase, the backend validates the transaction (cost, trial\neligibility, and — for real money — the receipt with Apple/Google), and the\nSDK mirrors the confirmed result into a local cache your UI reads. You never\nmutate premium state yourself — you call a method, check the result, and\nrender from the cache.\n\nThis skill is for **using** the production `PremiumService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(already trialed, receipt invalid, insufficient funds) — surface the error,\ndon't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n premium tiers: `Tier` number, `DurationDays`, `TrialDurationDays`, price\n options (virtual/item cost per option), App/Google product IDs, and\n free-form `Benefits` (display-only). Fetched with `getDefinitions()`.\n2. **User premium state** (state, per player) — this player's subscriptions\n (`Subscriptions`, keyed by `PremiumID`, including expired ones — they're\n never deleted), which trials they've already used (`ActivatedTrialIDs`,\n permanent), and their current best tier (`MaxActiveTier`). Fetched with\n `getUserState()`.\n\nA premium tier is identified by a string `PremiumID`. `Tier` is a plain number\n(higher = better) — it's what other modules compare against\n(`SegmentGate.MinPremiumTier`, `PremiumTierBundle.MinPremiumTier`) to decide\nwhether a perk applies; some gates instead pin an exact `RequiredPremiumID`,\nin which case the tier number is ignored. `MaxActiveTier` on the user state is\nthe number to read when you just need \"what's the player's current tier\"\nwithout walking `Subscriptions` yourself — the backend recalculates it from\nscratch (max `Tier` among non-expired subscriptions) on every read and write,\nso it self-heals even if a subscription lapsed since the last call.\n\nFor the full field-by-field shape, the trial-eligibility rules, tier\nresolution, and the purchase/renewal math, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (renewal countdowns, trial\neligibility, cost previews) off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst premium = client.premium; // the PremiumService\n```\n\nEvery premium method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing `TransactionID`/`ProductID`/`ReceiptData`),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window, default 600 ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Trial already used.\"`,\n`\"Subscription already active.\"`, `\"PriceOption not found\"`, insufficient\nfunds).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------- |\n| `getDefinitions()` | Load the title's premium tier catalog (config). | `PremiumDefinitionsResponse` (`Premium`) |\n| `getUserState()` | Load this player's subscriptions/trials/tier (state). | `PremiumStateResponse` (`Premium`) |\n| `activateTrial(premiumID, transactionID)` | Start a free trial of a tier (one-time per `PremiumID`, forever). | `PremiumPurchaseResponse` |\n| `purchaseItemOrCurrency(premiumID, transactionID, selectedOptionID?, count?)` | Buy/renew a tier with a virtual/item price option (default option `\"Default\"`, count 1). | `PremiumPurchaseResponse` |\n| `purchaseRealMoney(premiumID, transactionID, store, productID, receiptData, purchaseToken?, packageName?, appStoreEnvironment?)` | Send a real-money IAP receipt for validation. **Not implemented on the backend yet** — see Gotchas. | `PremiumPurchaseResponse` |\n\n`store` is `\"Apple\"` or `\"Google\"` (`StoreType`). `purchaseToken` is\nGoogle-specific (Play Billing purchase token); `packageName` and\n`appStoreEnvironment` are optional extra context some validators need. Every\npurchase/trial call requires a caller-supplied `transactionID` — treat it as\nthis attempt's client-side transaction id (distinct per attempt; the SDK also\nderives an internal idempotency key from it, and the backend independently\ntreats a repeated `transactionID` on an existing subscription as a no-charge\nreplay rather than a new purchase).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. The purchase methods also\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\n`packages/core/src/models/_shared/ResourceModels.ts`) to cached balances when\npresent; `activateTrial` always returns an empty `Resources` since a trial\ndoesn't move currency. Read updated tier/balances straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { PremiumDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<PremiumDefinitions>(\"Premium\");\nconst tier = defs?.Definitions?.[\"vip_gold\"];\ntier?.Tier; // numeric tier level\ntier?.PriceOptions; // { OptionID: { OptionID, Name, Cost, AllowedPlatforms } }\ntier?.Benefits; // free-form { key: stringified value } for display\n\n// User state (only present after getUserState() or a purchase/trial):\nconst p = client.data.user.state?.Premium;\np?.MaxActiveTier; // current best tier (number), self-healing on every fetch\np?.Subscriptions?.[\"vip_gold\"]?.ExpirationDate; // check this, not just key existence\np?.ActivatedTrialIDs; // trials already used, ever — don't offer them again\n```\n\n`applyPremium` fully **replaces** the cached `Premium` object on every write\n(`packages/core/src/cache/UserData.ts:672`) — it's not a deep merge, so a\n`getUserState()`/purchase/trial response always reflects the complete,\nauthoritative state.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `premium:definitionsLoaded` → `PremiumDefinitionsResponse`\n- `premium:stateLoaded` → `PremiumStateResponse`\n- `premium:trialActivated` → `PremiumPurchaseResponse`\n- `premium:purchaseCompleted` → `PremiumPurchaseResponse`\n- `premium:realMoneyPurchaseCompleted` → `PremiumPurchaseResponse`\n\nThe coarse `user:premiumUpdated` (and `user:anyUpdated`) also fire on any\npremium cache write — handy for a \"re-render everything\" hook, and useful for\nany other screen that displays a tier-gated perk.\n\n```ts\nconst off = client.on(\"premium:purchaseCompleted\", (r) => {\n console.log(`Now on ${r.Subscription?.PremiumID}, tier state refreshed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show tiers and the player's current status\n\n```ts\nawait client.premium.getDefinitions();\nawait client.premium.getUserState();\n\nconst defs = client.data.config.getSection<PremiumDefinitions>(\"Premium\");\nconst state = client.data.user.state?.Premium;\n\nfor (const [premiumID, tier] of Object.entries(defs?.Definitions ?? {})) {\n const active = state?.Subscriptions?.[premiumID];\n const isActive =\n !!active?.ExpirationDate && new Date(active.ExpirationDate) > new Date();\n const canTrial =\n (tier.TrialDurationDays ?? 0) > 0 &&\n !state?.ActivatedTrialIDs?.includes(premiumID);\n // isActive drives \"renews on\"/\"expired\" copy;\n // canTrial drives whether to show a \"Start free trial\" button.\n}\n```\n\n### Activate a free trial\n\n```ts\nconst res = await client.premium.activateTrial(\"vip_gold\", crypto.randomUUID());\nif (!res.ok) return showError(res.error); // e.g. \"Trial already used.\"\nres.data.Subscription?.ExpirationDate; // when the trial ends\n```\n\nOnly tiers with `TrialDurationDays > 0` support this — otherwise the backend\nrejects with `\"Trial is not available for this premium.\"` A trial also can't\nbe started on top of an already-active subscription to the same tier\n(`\"Subscription already active.\"`).\n\n### Purchase a tier with virtual currency\n\n```ts\nconst res = await client.premium.purchaseItemOrCurrency(\n \"vip_gold\",\n crypto.randomUUID(),\n \"Default\",\n 1,\n);\nif (!res.ok) return showError(res.error); // e.g. \"PriceOption not found\", insufficient funds\n// cache now has the new/renewed subscription + debited balances.\n```\n\nIf the player already has this exact tier active, the purchase **extends**\nits `ExpirationDate` rather than starting a fresh countdown or stacking a\nsecond entry — see\n[the renewal rule](references/data-model.md#purchase-with-virtual-currency--items).\nThe chosen `selectedOptionID` must exist in that tier's `PriceOptions`, and\nthat option must carry a non-empty virtual cost — an option with an empty `Cost`,\nor one paid in a store (a `Purchase` entry), belongs to the real-money flow and\nthis call rejects it.\n\n### Complete a real-money purchase (App Store / Google Play) — not yet backed\n\n```ts\n// After your platform IAP SDK confirms the purchase and hands you a receipt:\nconst res = await client.premium.purchaseRealMoney(\n \"vip_gold\",\n crypto.randomUUID(),\n \"Apple\",\n \"com.yourgame.vip_gold_monthly\",\n receiptData, // base64 receipt / signed transaction payload\n);\nif (!res.ok) return showError(res.error);\n```\n\nThe method, request shape, and events are implemented client-side, but the\ncurrent backend has **no handler** for this action — calling it returns\n`reason: \"server\", error: \"Action not implemented\"` (see Gotchas below and\n[the reference](references/data-model.md#real-money-iap-purchase--current-backend-status)\nfor exactly why). Do not wire this into a shipping purchase button yet; treat\nit as a documented but currently-nonfunctional call.\n\n### Read the tier elsewhere (discounts/multipliers)\n\nOther modules don't expose a \"premium\" parameter — they read the cached tier\nthemselves server-side when computing `ResourceConsume.PremiumDiscounts` /\n`ResourceGrant.PremiumTiers` / `SegmentGate.MinPremiumTier`. Client-side, use\n`MaxActiveTier` only to preview/gray-out UI (e.g. \"Requires VIP Gold+\" on a\nstore offer or reward tier); the actual discount/bonus is applied by the\nbackend and arrives in that call's own `Resources`.\n\n```ts\nconst myTier = client.data.user.state?.Premium?.MaxActiveTier ?? 0;\nconst locked = (bundle.MinPremiumTier ?? 0) > myTier;\n```\n\n## Gotchas\n\n- **`purchaseRealMoney` is not implemented on the backend.** The v2\n `Premium.cs` HTTP handler's action switch only has cases for\n `GetDefinitions`, `GetUserState`, `ActivateTrial`, and\n `PurchaseWithResources` — `PurchaseRealMoney` falls through to\n `default: \"Action not implemented\"`\n (`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:52-69`). Real-money receipt\n validation exists in the backend only under the legacy **v1** surface\n (`ValidateIAP.cs`/`ValidateIAPSubscription.cs`), which is a separate\n endpoint family not reachable through `client.premium`. Don't build a\n shipping IAP-subscription flow on this call until the backend adds a real\n handler.\n- **`ActivatedTrialIDs` is permanent and never cleared.** A trial is one-time\n per `PremiumID` per account for the lifetime of the account — cancelling,\n expiring, or unsubscribing doesn't remove the id. Check it client-side\n before showing a trial CTA rather than relying only on the server\n rejection (`\"Trial already used.\"`).\n- **An entry in `Subscriptions` isn't necessarily active.** Expired\n subscriptions are kept, not deleted (they support renewal-on-top-of-lapse\n math and history). Always check `ExpirationDate`, or just trust\n `MaxActiveTier`, which already excludes expired/unknown tiers.\n- **Tiers take the max, they don't stack.** Holding two active subscriptions\n at once doesn't add their `Tier`s — `MaxActiveTier` is simply the highest\n `Tier` among currently-active subscriptions.\n- **Buying an already-active tier extends it, it doesn't restart it.** The\n new duration is added on top of the existing `ExpirationDate`; buying tier\n X again while X is still active is a renewal, not a discard-and-replace.\n- **Guard against double-submit.** Each call needs a caller-supplied\n `transactionID`; reusing the same one for a retry is fine (that's what it's\n for — both the trial and purchase endpoints detect a matching\n `TransactionID` on the existing subscription and return the current state\n with no new charge), but a double-clicked \"Subscribe\" with two _different_\n generated ids is two real attempts. Disable the control while a call is in\n flight. Firing the same endpoint again within the client throttle window\n (default 600 ms) is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.\n- **`Resources` is nullish only in shape, not in practice.** Trial responses\n and idempotent-replay responses always come back with an explicit empty\n `ResourceOperation` (never `null`) — but the type is still nullish, so\n guard before reading into it anyway.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the exact tier-resolution algorithm, trial eligibility rules,\nthe purchase/renewal math, verbatim backend rejection strings, the current\nreal-money-purchase gap, and how other modules' gate/discount/multiplier\ntypes reference Premium's `Tier`/`MaxActiveTier`. Read it when building\nconfig-driven UI (renewal countdowns, trial eligibility, cost previews) or\nwhen an error message points at a rule you need to understand.\n",
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`, `PremiumPriceOption`) 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- [PremiumPriceOption](#premiumpriceoption)\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, PremiumPriceOption>; // 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## PremiumPriceOption\n\nOne payment option within a `PremiumDefinition.PriceOptions` map.\n\n```ts\ninterface PremiumPriceOption {\n OptionID?: string; // key within PriceOptions, e.g. \"Default\", \"bundle_a\"\n Name?: string; // optional display name, e.g. \"For Gold\"\n RequiredResources?: ResourceConsume; // debit-only cost; see below\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:117-140`.\n\n`RequiredResources` is a standard `ResourceConsume`\n(`packages/core/src/models/_shared/ResourceModels.ts`) — cost lives in\n`RequiredResources.Standard.Entries` (items/currencies) and/or\n`RequiredResources.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 RequiredResources. 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`RequiredResources` 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 `RequiredResources` must carry at least one item/currency\n entry or event-token entry — otherwise\n `\"This purchase option has no RequiredResources. 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 (`RequiredResources`, 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 RequiredResources. 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\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"
9
9
  }
10
10
  ]
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Quest data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nresolution rules, the points-track (\"Achievements\") plumbing, group-completion\nmath, and the server-side limits/idempotency rules. All of these are **strictly\ntyped in the SDK** — `QuestDefinitions` and every nested block (`QuestDefinition`\nand its `QuestIdentity`/`QuestLinking`/`QuestAvailability`/`QuestReward` blocks,\n`QuestCycleDefinition`, `QuestPhaseDefinition`, `QuestObjectiveDefinition`,\n`QuestGroupCompletionDefinition`, `QuestPresetRegistry`/`QuestPresetBindings`,\nthe shared `ScheduleSpec`/`SegmentGate`/`LimitSpec`/`MilestoneDefinition`/\n`EventTokenDefinition` blocks, …) are exported from `@idosgames/core`, so\n`getQuestDefinitions()` and `getSection<QuestDefinitions>(\"Quest\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight from\nthe backend JSON).\n\nBackend source of truth for everything below:\n`IDosGamesSDK/API/Client/v2/Quest/Quest.cs`,\n`IDosGamesSDK/API/Client/v2/Quest/Models/QuestDefinitions.cs`,\n`IDosGamesSDK/API/Client/v2/Quest/Models/UserQuestState.cs`,\n`IDosGamesSDK/API/Core/Scheduling/Services/ScheduleResolver.cs`,\n`IDosGamesSDK/API/Core/Event/Services/EventTokenService.cs`.\n\n## Contents\n\n- [Player state](#player-state) — what `getUserQuestState()` returns\n- [Config: QuestDefinitions](#config-questdefinitions) — what `getQuestDefinitions()` returns\n- [QuestCycleDefinition](#questcycledefinition)\n- [QuestDefinition](#questdefinition)\n- [Presets](#presets--authoring-n-days--m-tasks-without-nm-copies) — authoring N days × M tasks without N×M copies\n- [QuestObjectiveDefinition + progress aggregation](#questobjectivedefinition--progress-aggregation)\n- [Prerequisites (`RequiredQuestIDs`)](#prerequisites-requiredquestids)\n- [Cycle schedule resolution](#cycle-schedule-resolution)\n- [Per-quest schedule (\"staged unlock\" / Achievements)](#per-quest-schedule-staged-unlock--achievements)\n- [Points track (\"Achievements\") — the Quest event-token](#points-track-achievements--the-quest-event-token)\n- [Group-completion (grand reward) math](#group-completion-grand-reward-math)\n- [`AddQuestProgress` server-side rules](#addquestprogress-server-side-rules)\n- [Idempotency, atomicity, batch limits](#idempotency-atomicity-batch-limits)\n\n---\n\n## Player state\n\nReturned by `getUserQuestState()` as `{ State, PointsTracks }` and cached at\n`client.data.user.state?.Quest` (progress) + `client.data.user.state?.EventToken?.Quest`\n(points track — see below). Hand-written interfaces (not `z.infer`) because the\ncache-patch methods mutate these objects in place.\n\n```ts\ninterface UserQuestState {\n Cycles?: Record<string, UserQuestCycleState>; // key = CycleID\n PermanentQuests?: Record<string, UserQuestProgress>; // key = QuestID\n LastUpdatedUtc?: string;\n}\n\ninterface UserQuestCycleState {\n CycleID?: string;\n CycleStartUtc?: string; // current window start, UTC\n CycleEndUtc?: string; // current window end, UTC\n Quests?: Record<string, UserQuestProgress>; // key = QuestID, THIS window only\n ClaimedGroupCompletionIDs?: string[]; // CompletionIDs already claimed this window\n}\n\ninterface UserQuestProgress {\n QuestID: string;\n Status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n ActivatedAtUtc?: string | null;\n CompletedAtUtc?: string | null;\n ClaimedAtUtc?: string | null;\n Objectives?: Record<string, UserQuestObjectiveProgress>; // key = ObjectiveID\n}\n\ninterface UserQuestObjectiveProgress {\n ObjectiveID: string;\n CurrentValue: number;\n Completed: boolean;\n CompletedAtUtc?: string | null;\n}\n```\n\n**Lazy initialization** (`Quest.cs` `RefreshQuestCycles` / `CreateQuestProgressFromDefinition`):\na quest's `UserQuestProgress` (and each objective's `UserQuestObjectiveProgress`)\nis created only the first time progress is reported for it — the server does\n**not** pre-populate every configured quest/objective with zeros. A quest absent\nfrom `Cycles[cycleID].Quests` (or `PermanentQuests`) simply has zero progress on\nevery objective; render it as `\"Active\"`, not as an error or \"unknown\" state.\n`Expired` is only ever set for cyclic quests (never for permanent quests) and\nonly via the same lazy path — in practice you will see `Active` → `Completed` →\n`Claimed` for anything you've touched; a truly stale quest from a rolled-over\ncycle is simply absent (the whole cycle bucket gets replaced on rollover, see\nbelow), not flagged `Expired` in current code paths.\n\n---\n\n## Config: QuestDefinitions\n\nReturned by `getQuestDefinitions()`; cached via\n`client.data.config.getSection<QuestDefinitions>(\"Quest\")`.\n\n```ts\ninterface QuestDefinitions {\n Cycles?: Record<string, QuestCycleDefinition>; // key = CycleID\n Quests?: Record<string, QuestDefinition>; // key = QuestID\n Presets?: QuestPresetRegistry; // reusable blocks, one registry per QuestDefinition block\n}\n```\n\n**Quests arrive already assembled.** The config is *authored* compactly — a field left unset on a\nquest comes from the preset bound to that block — but the backend resolves it once when it\nmaterializes the title config, so what `getQuestDefinitions()` returns already has every quest's\nblocks filled in. `Presets` rides along for editors; a game client never merges anything.\n\nAssembled is not the same as flattened: the **shape** stays blocked. A quest's name is at\n`Identity.DisplayName`, its cycles at `Linking.CycleIDs`, its window at `Availability.Schedule`,\nits payout at `Reward.Grant`.\n\nA quest is **permanent** iff its `Linking.CycleIDs` is null/empty; otherwise it is\n**cyclic** and belongs to every cycle listed there (a quest can appear\nin more than one cycle definition, each with independent progress/claim state).\nCycle IDs and quest/objective IDs must all be Mongo-safe (no `.` or `$`) —\nthe server rejects unsafe keys outright (`\"Invalid CycleID (mongo-unsafe): …\"`,\n`\"QuestID is mongo-unsafe\"`, etc.); this only matters if you let players or\nremote config drive raw ids into these fields.\n\n---\n\n## QuestCycleDefinition\n\n`Quest.cs` / `QuestDefinitions.cs`. One recurring or one-off \"board\" (dailies,\nweeklies, a scheduled event window, …). Quests do **not** get listed here —\neach `QuestDefinition` points back at the cycle via `Linking.CycleIDs`.\n\n```ts\ninterface QuestCycleDefinition {\n CycleID?: string;\n DisplayName?: string;\n Schedule?: ScheduleSpec; // cycle window/reset — see below\n Milestones?: Record<string, MilestoneDefinition>; // points-track rungs, key = MilestoneID\n Phases?: Record<string, QuestPhaseDefinition>; // chain phases, only for Schedule.Mode = \"Chained\"\n Presets?: { Milestones?: PresetBinding }; // cycle-level preset wiring (Core/Presets)\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n Gate?: SegmentGate; // audience gate for the whole cycle; ANDed with each quest's own Gate\n PointsToken?: EventTokenDefinition; // global caps/burn for the points track (see below)\n GroupCompletions?: Record<string, QuestGroupCompletionDefinition>; // key = CompletionID\n}\n```\n\nBackend default when a cycle is authored without an explicit `Schedule`:\n`Mode: \"Cyclic\"`, `Cyclic: {}` (i.e. daily calendar reset) —\n`QuestCycleDefinition.Schedule` in `QuestDefinitions.cs`.\n\n---\n\n## QuestDefinition\n\nOnly the ID lives at the root; everything else is a named block, exactly like\n`CharacterDefinition` (`Identity` / `Classification` / `Unlock` / `Stats` / …).\n\n```ts\ninterface QuestDefinition {\n QuestID?: string;\n Identity?: QuestIdentity;\n Linking?: QuestLinking;\n Availability?: QuestAvailability;\n Objectives?: Record<string, QuestObjectiveDefinition>; // key = ObjectiveID; ALL must complete\n Reward?: QuestReward;\n Presets?: QuestPresetBindings; // one binding per block — see Presets\n}\n\n/** Display part — analogous to CharacterIdentity. */\ninterface QuestIdentity {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number; // lower = earlier in UI; default 0\n AssetPaths?: Record<string, string>; // task icon and other client assets\n CustomParams?: Record<string, string>; // passed to the client untouched\n}\n\n/** Links — analogous to CharacterClassification. */\ninterface QuestLinking {\n CycleIDs?: string[]; // null/empty => permanent; else one entry per cycle it appears in\n GroupID?: string; // plain label: UI sections + QuestGroupCompletionDefinition matching\n PhaseIDs?: string[]; // chain phases this quest lives in; empty = all\n RequiredQuestIDs?: string[]; // prerequisite QuestIDs — see below\n PrerequisiteMode?: \"BlockProgressAndClaim\" | \"BlockClaimOnly\"; // default BlockProgressAndClaim\n}\n\n/** Access rules — analogous to CharacterUnlock. */\ninterface QuestAvailability {\n Schedule?: ScheduleSpec; // per-quest unlock window; unset = the cycle's window (see below)\n AccrueProgressWhenLocked?: boolean; // default false — see per-quest schedule section\n Gate?: SegmentGate; // ANDed with the cycle's Gate\n Limits?: LimitSpec; // per-source caps on POINTS grants only (not on objective progress)\n}\n\n/** Claim payout: the grant plus points into the cycle track. */\ninterface QuestReward {\n Grant?: ResourceGrant; // claimed via claimQuestReward\n PointsReward?: number; // points into the cycle's track on claim; ignored for permanent quests\n}\n```\n\nThere is **no group entity.** `Linking.GroupID` is a plain string: it groups quests into UI\nsections and it is what `QuestGroupCompletionDefinition` matches on. Nothing has to declare it,\nand nothing inherits through it.\n\nIn the **stored** config every block, and every field inside it, is optional in the strong sense —\nabsent means \"take it from the preset bound to this block\" (see\n[Presets](#presets--authoring-n-days--m-tasks-without-nm-copies)). By the time this reaches a\nclient the backend has already assembled them.\n\n`PrerequisiteMode` (`QuestDefinitions.cs` comment, verbatim intent):\n\n| Mode | Effect on `RequiredQuestIDs` |\n| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `BlockProgressAndClaim` (default) | The quest does not start accruing progress at all until every listed prerequisite reaches `Completed`/`Claimed`; consequently it also can't be claimed. |\n| `BlockClaimOnly` | Progress accrues immediately; only the final reward claim is blocked until prerequisites are met. |\n\nA prerequisite is looked up \"where its own progress lives\": permanent →\n`PermanentQuests`; cyclic → the same `cycleID` if the prerequisite also belongs\nto it, otherwise the prerequisite's own first `Linking.CycleIDs` entry\n(`ArePrerequisitesMet`, `Quest.cs`). Empty/null `RequiredQuestIDs` = no gating.\n\n---\n\n## Chains — a cycle that runs phases one after another\n\nSet the cycle's `Schedule.Mode` to `\"Chained\"` and fill `Phases`. The chain starts at\n`Schedule.Chain.AnchorUtc`, plays its phases in `Order`, then repeats (`MaxCycles`, pauses).\n\n```ts\ninterface QuestPhaseDefinition {\n PhaseID?: string; // unique within the chain; referenced by QuestLinking.PhaseIDs\n Order?: number; // position within one full pass (0, 1, 2...)\n DurationSec?: number; // how long the phase stays open\n ClaimGraceHours?: number;// extra claim window after it ends\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n Gate?: SegmentGate; // AND-ed with cycle gate and quest gate\n Milestones?: Record<string, MilestoneDefinition>; // null = the cycle's ladder is used\n Presets?: { Milestones?: PresetBinding };\n PointsToken?: EventTokenDefinition; // null = the cycle's token\n}\n```\n\nThree rules worth knowing before designing one:\n\n- **Every phase owns its progress.** The points-track address includes the instance key, and for\n a phase that key is `chain:{cycleIndex}:{phaseID}`. So week 2 starts from zero points with its\n own claimed-milestone list — it never inherits week 1. The cycle's quest state resets at a phase\n boundary exactly like it resets at midnight for a `Daily` cycle.\n- **Unset phase content falls back to the cycle.** Eight identical weeks are eight phases with\n only `Order`/`DurationSec` filled — not eight copies of the milestone ladder. The exception is an\n *empty* (not absent) `Milestones` object: that explicitly means \"no milestones in this phase\".\n- **A `Chained` cycle with no phases never activates.** There is nothing to resolve, so the whole\n cycle stays closed and its quests never progress.\n\nBind a quest to specific phases with `Linking.PhaseIDs` (empty = every phase). It gates\nboth progress and claiming — a week-2 quest cannot be claimed during week 1, in single and batch\nclaims alike. `PhaseIDs` is the direct way to say \"this quest belongs to week two\"; the quest's own\n`Availability.Schedule` with a `Relative` window stays for staged unlocking *within* one phase\n(\"Day N\").\n\n`GetUserQuestState` reports the live phase on each track: `PointsTracks[cycleID].PhaseID` and\n`.CycleIndex` (both absent/0 for a plain cycle) alongside `CycleStartUtc`/`CycleEndUtc` of that\nphase — enough to render \"Week 2 of 8\" and a countdown.\n\n### Milestone presets\n\n`QuestDefinitions.Presets.Milestones` is a registry of reusable `MilestoneSet`s keyed by PresetID —\nthe one preset block that belongs to cycles and phases rather than to quests. A cycle or a phase\nreferences one through `Presets.Milestones` (`PresetBinding`): the preset is the base, the inline\n`Milestones` dictionary overrides or adds by MilestoneID, and `Remove` drops keys. No PresetID ⇒\ninline only. An unknown PresetID silently falls back to inline — it never wipes the entity's own\nladder. Everything else about presets is in the next section.\n\n---\n\n## Presets — authoring N days × M tasks without N×M copies\n\nA seven-day event of six tasks a day is 42 quests that differ in three numbers. One mechanism\nexists so the config says that once instead of 42 times, and it is the **same one Character\nuses**: a registry of reusable blocks plus a binding per block. There is no second mechanism —\nno group entity, no chassis, no inheritance chain. It is **authoring-side only**: the backend\nresolves it at config load and everything downstream sees ordinary assembled quests.\n\n**The one rule:** *unset = take it from the preset, set = final.* A field that is absent takes its\nvalue from the preset bound to that block; a field that is present — **including `0`, `false` and\n`[]`** — wins and is never overwritten. That asymmetry is deliberate: \"this quest gives no points\"\n(`Reward.PointsReward: 0`) has to survive against a preset that grants 30.\n\n```ts\n/** Registry: one dictionary per block, each mirroring the same-named QuestDefinition block. */\ninterface QuestPresetRegistry {\n Milestones?: Record<string, MilestoneSet>; // cycles and chain phases only\n Linking?: Record<string, QuestLinking>;\n Availability?: Record<string, QuestAvailability>;\n Reward?: Record<string, QuestReward>;\n Objectives?: Record<string, Record<string, QuestObjectiveDefinition>>; // inner key = ObjectiveID\n}\n\n/** Wiring: one binding per block, exactly like CharacterDefinition.Presets. */\ninterface QuestPresetBindings {\n Milestones?: PresetBinding; // on a cycle / phase, not on a quest\n Linking?: PresetBinding;\n Availability?: PresetBinding;\n Reward?: PresetBinding;\n Objectives?: PresetBinding; // merges by ObjectiveID; `Remove` drops preset entries\n}\n```\n\n**Bindings are independent.** Take the schedule from one preset, the reward from another, and\nwrite the objectives inline — the blocks don't know about each other. Precedence inside one\nblock is just two layers:\n\n```\nquest's own field → the preset bound to that block → engine default\n```\n\n**`Identity` has no preset on purpose.** A quest's name and sort order are unique to it, and\n`Description` — the only field that is ever shared — is displayed by no client, so a registry for\nthis block added a binding to every quest and carried nothing. Write Identity inline.\n\nSingle-object blocks (`Linking` / `Availability` / `Reward`) merge **field by field**. `Objectives` merges **by ObjectiveID**, and inside a matched objective the same\nunset-takes-from-preset rule applies — that is the piece that pays for itself: the preset says\n*how* an objective advances, the quest restates only what differs.\n\n```jsonc\n// preset: how \"make N moves\" works — written once\n\"Presets\": { \"Objectives\": { \"moves\": {\n \"task\": { \"Source\": \"SystemEvent\", \"TargetValue\": 15,\n \"Triggers\": [{ \"SourceType\": \"BoardTileLanding\" }] } } } }\n\n// day 5's quest: name and target are all that is unique\n\"Quests\": { \"e7_d5_moves\": {\n \"Identity\": { \"DisplayName\": \"Day 5. Make 35 moves\", \"SortOrder\": 501 },\n \"Presets\": {\n \"Linking\": { \"PresetID\": \"e7\" }, // cycle + group label, shared by all 42\n \"Availability\": { \"PresetID\": \"e7_d5\" }, // \"opens 4 days after the event starts\"\n \"Reward\": { \"PresetID\": \"e7_d5\" }, // day-5 payout, shared by that day's 6 tasks\n \"Objectives\": { \"PresetID\": \"moves\" }\n },\n \"Objectives\": { \"task\": { \"TargetValue\": 35 } } // triggers survive — only the number changes\n}}\n```\n\nThree things that bite if you don't know them:\n\n- **The dictionary key is the ID.** A quest or objective written without `QuestID` /\n `ObjectiveID` takes it from its key. In the compact form it is easy to omit, and an objective\n with no ID used to be skipped silently — the quest looked configured and never moved.\n- **An unknown PresetID falls back to inline**, it never wipes the block. A typo therefore shows\n up as a quest with a missing window or a missing reward, not as an error at load.\n- **`Remove` on `Presets.Objectives`** is the only way to take a preset objective away for a\n single quest.\n\n---\n\n## QuestObjectiveDefinition + progress aggregation\n\n```ts\ninterface QuestObjectiveDefinition {\n ObjectiveID?: string;\n Source?: \"ClientApi\" | \"ServerApi\" | \"SystemEvent\"; // what advances it; default ClientApi\n MaxProgressPerCall?: number; // ClientApi BAN threshold (not a clamp); 0/absent = no check\n MetricID?: string; // ClientApi/ServerApi only: the metric key reported against\n Triggers?: TriggerSource[]; // SystemEvent only: in-game events that advance it\n TargetValue?: number; // default 1; required value to complete\n AggregationMethod?: string; // \"Sum\" | \"Maximum\" | \"Minimum\" | \"Last\"; default \"Sum\"\n}\n```\n\n`Source` selects **which field is read** — they are mutually exclusive:\n\n| Source | Advanced by | Field read |\n| ------------- | ----------------------------------------------- | ------------ |\n| `ClientApi` | the game calling `addQuestProgress(MetricID, v)` | `MetricID` |\n| `ServerApi` | a CloudCode script calling `server.AddQuestProgress(MetricID, v)` | `MetricID` |\n| `SystemEvent` | the backend itself, on in-game events | `Triggers` |\n\n`addQuestProgress` reaches **only** `ClientApi` objectives; a `MetricID` with no\nmatching `ClientApi` objective anywhere in the catalog is rejected outright\n(`\"MetricID not allowed for ClientApi\"`).\n\n### `Triggers` — SystemEvent objectives\n\n`Triggers` is the shared `TriggerSource` used by `EventContent.TokenSources`\n(TimedEvent) and `LeaderboardDefinition.ScoreSources`: an event type plus\nfilters, with `BaseWeight` as the progress step per fire. The list is OR-ed\n(first match wins). Empty/absent ⇒ the objective never advances.\n\nThe backend emits these event types into quests — anything else in\n`EventTokenSourceType` never reaches a quest (use `ClientApi` for\nclient-observed actions like watching an ad):\n\n`BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`, `BoardBuild`,\n`BoardStageComplete`, `BoardSpecialComplete` (GameLoop) · `StorePurchase`\n(`Store.Purchase` / `PurchaseBatch`, multiplier = purchase count) ·\n`MarketplaceSell` / `MarketplaceBuy` (settlement, **acting player only**) ·\n`QuestComplete` (`ClaimQuestReward`, for meta-quests) · `DailyLogin` (first login of a UTC day —\ndeduped at login, so ten re-entries in one evening count as one day) · `CurrencySpent`\n(`ResourceService`, on the applied consume; multiplier = **amount spent**) · `LootboxOpened`\n(multiplier = boxes opened in the call) · `LeaderboardRankReward` (fired when a rank reward is\nactually claimed, not while the standing changes) · `IapPurchase` (`PurchaseV2`, after the receipt\nis verified and the goods granted; multiplier = units granted — **also fires on subscription\nauto-renewals** from the store callback, tagged `Renewal: \"true\"`) · `CryptoDeposit` / `CryptoWithdraw`\n(deposit credited / withdrawal **confirmed on chain** — not on the request, which may never land;\nmultiplier = 1 operation) · `CryptoSpent` (crypto consumed in-game; multiplier = amount) ·\n`CurrencyEarned` / `CryptoEarned` (`ResourceService`, on the applied **grant**, premium tiers\nincluded; multiplier = **amount granted**).\n\nTwo of these carry an *amount* in the multiplier rather than a count, which makes\n`ScaleWithRollMultiplier` the switch between two different goals:\n\n| Source | `true` | `false` |\n| ------ | ------ | ------- |\n| `CurrencySpent` | \"spend 100 coins\" | \"make 100 separate spends\" |\n| `CryptoSpent` | \"spend 100 tokens\" | \"make 100 separate spends\" |\n| `CurrencyEarned` | \"earn 1000 coins\" | \"receive coins 1000 times\" |\n| `CryptoEarned` | \"earn 100 tokens\" | \"receive tokens 100 times\" |\n| `LootboxOpened` | \"open 15 chests\" (one call of 15 counts fully) | \"open a chest 15 times\" |\n| `IapPurchase` | \"buy 5 units\" (a x5 pack counts fully) | \"make 5 separate purchases\" |\n\nSoft currency, crypto and real money are three **separate** sources on purpose: a goal like\n\"spend 100\" must not be closeable by coins one day and by tokens or dollars the next. If a title\nstores crypto in minimal (wei-like) units, set `ScaleWithRollMultiplier: false` on `CryptoSpent`\nand count operations — the amount would otherwise be astronomically large.\n\n`Params` filters the matcher actually checks: `StorePurchase` → `OfferID`;\n`QuestComplete` → `QuestID`, `CycleID`; `Marketplace*` → `CatalogID`, `ItemID`,\n`OfferType`; `CustomAction` → `ActionName`; `CurrencySpent` → `CurrencyID`;\n`LootboxOpened` → `LootboxID`; `IapPurchase` → `ProductID`, `Store`, `Renewal`\n(`\"true\"` = subscription auto-renewal, `\"false\"` = the player bought it by hand; omit to count\nboth — money was paid either way);\n`CryptoDeposit` / `CryptoWithdraw` → `CurrencyID`, `NetworkID`; `CryptoSpent` → `CurrencyID`;\n`CurrencyEarned` / `CryptoEarned` → `CurrencyID`, `Origin`\n(`\"Gameplay\"` = only what the game paid out, `\"RewardClaim\"` = only quest/milestone/rank/season/daily\npayouts, omit to count both — a goal like \"earn 1000 coins\" is otherwise partly closed by other\nquests' rewards);\n`LeaderboardRankReward` → `LeaderboardID`, `Rank`\n(exact match — \"first place\" is `Rank: \"1\"`; for \"top 3\" declare three sources or omit `Rank`).\nAny other key is stored but ignored.\n\nSet `ScaleWithRollMultiplier: false` on a quest trigger unless you *want* the\nboard's roll multiplier to inflate the step — otherwise \"win 3 raids\" closes on\na single x3 raid.\n\n`SystemEvent` progress is applied **after** the handler succeeds and returns on\nthat response's envelope as `QuestProgress: QuestProgressUpdate[]` (absent when\nnothing moved). The guarantee is at-most-once: the game action is already\ncommitted, so a failure here loses the event rather than rolling the action back.\n\nAggregation (`ApplyAggregation`, `Quest.cs`), given the objective's current\n`CurrentValue` and the incoming call value:\n\n| Method | New value |\n| ------------------------ | ---------------------------------------------------------------------------------------- |\n| `Sum` (default) | `current + incoming`, clamped to `long.MaxValue` on overflow; `incoming <= 0` is a no-op |\n| `Maximum` | `max(current, incoming)` |\n| `Minimum` | `incoming` if `current == 0`, else `min(current, incoming)` |\n| `Last` (or unrecognized) | `incoming` (last-write-wins) |\n\nAfter aggregation, if `TargetValue > 0` the new value is clamped to\n`TargetValue` (progress bars never overshoot 100%); if `TargetValue <= 0` there\nis no cap. An objective is marked `Completed` once\n`(TargetValue <= 0 && newValue > 0) || newValue >= TargetValue`. A quest becomes\n`\"Completed\"` once **every** objective the player has a progress record for is\n`Completed` **and** every objective in the definition has a progress record —\ni.e. an objective with zero recorded progress blocks completion (it's absent\nfrom the player's `Objectives` map, so the `All(...)` check in\n`EnsureQuestObjectivesAndCompletion` fails for it).\n\n`MaxProgressPerCall` guards two different things depending on\n`AggregationMethod`:\n\n- If **any** matching `ClientApi` objective for the `MetricID` has\n `MaxProgressPerCall > 0`, the server compares the **raw** `ProgressValue` you\n sent against the (minimum across matches) cap **before** any clamping. If the\n raw value exceeds it, the call is rejected **and the player is banned**\n (`service.BanUser(...)`, fire-and-forget) with error `\"User banned: Value\nexceeds MaxValuePerCall\"`. This is a hard anti-abuse trip-wire, not a soft\n clamp — never let client code send inflated values \"to be safe.\"\n- Only for objectives using `Sum` aggregation is the value additionally\n clamped to `MaxProgressPerCall` before summing (defense in depth; irrelevant\n once the ban-check above has already passed, since raw ⇐ cap by that point).\n\n---\n\n## Prerequisites (`RequiredQuestIDs`)\n\nSee the `PrerequisiteMode` table above. Enforcement points in `Quest.cs`:\n\n- **Progress accrual** (`AddQuestProgress` internal helper): for permanent\n quests and for each cycle a cyclic quest belongs to, prerequisites are\n checked (only in `BlockProgressAndClaim` mode) before the quest's\n `UserQuestProgress` is even created/updated for that call.\n- **Claim** (`ClaimQuestReward` / batch): `ArePrerequisitesMet(...)` is checked\n unconditionally (both modes gate the claim) — error\n `\"Prerequisite quests are not completed\"`.\n\n---\n\n## Cycle schedule resolution\n\n`QuestCycleDefinition.Schedule` is a `ScheduleSpec` (`_shared/ScheduleModels.ts`\n/ `Core/Scheduling`), the same primitive every other module uses. For Quest,\n`RefreshQuestCycles` resolves it via `ScheduleResolver.ResolveActive(...)`\ninto a `[CycleStartUtc, CycleEndUtc)` window per the active `Mode`:\n\n- **`Cyclic`** (the practical default for dailies/weeklies): `Cyclic.Reset` picks\n the calendar cadence — `Hourly`/`Daily`/`Weekly` (always **Monday** start)/`Monthly`\n (always the **1st**)/`Yearly` are calendar-aligned in **UTC**, reset time is\n **always 00:00:00 UTC** and is not configurable. `FixedInterval` instead repeats\n every `Cyclic.IntervalSeconds` seconds from `Cyclic.AnchorUtc` (default anchor\n `2026-01-01T00:00:00Z`, default interval 86400s if unset/≤0); an optional\n `PauseBetweenCyclesSec` inserts a dead gap after each active window during\n which `CanEarn` is `false` (`IsInPause = true`) but the window has still\n technically \"ended\" — new progress does not accrue during the pause, though\n already-completed quests remain claimable (claims are never earn-gated).\n- **`Scheduled`**: one fixed `[StartUtc, EndUtc]` window; `ClaimGraceHours`\n extends claimability past `EndUtc` without extending earning (unless\n `AllowEarningAfterEnd` is set).\n- **`AlwaysOn`**: always active, no end.\n- Any other/inactive resolution (e.g. `Triggered`, or `spec.IsActive === false`)\n makes `ComputeCycleWindowUtc` **degrade to a plain UTC calendar day**\n `[today 00:00, tomorrow 00:00)` as a fallback — don't configure `Triggered` on\n a quest cycle expecting anything else.\n\n**Rollover behavior** (`RefreshQuestCycles`, called automatically by\n`getUserQuestState({autoRefreshCycles: true})` — the default — and before every\nmutating Quest action): when the resolved `[start, end)` no longer matches the\nstored `CycleStartUtc`/`CycleEndUtc`, the entire `UserQuestCycleState` for that\ncycle is **replaced with a brand-new, empty one** (`Quests: {}`,\n`ClaimedGroupCompletionIDs` reset) — there is no partial carry-over of\nin-progress quests into the new window. Cycles removed from config entirely are\ndeleted from the player's state on the next refresh. If the window has **not**\nrolled over, the refresh instead walks the player's **existing** quest progress\nrecords (only ones already started) and re-evaluates `Completed` status against\ncurrent config — it does not add new objectives to already-tracked quests.\n\n---\n\n## Per-quest schedule (\"staged unlock\" / Achievements)\n\n`QuestAvailability.Schedule` is an **independent, optional** `ScheduleSpec` layered\non top of the cycle's own schedule — this is how \"Day 2 unlocks 24h after Day 1\"\nor an \"Achievements\" track with a `ScheduleSpec`-driven unlock (rather than a\nliteral day-count) is built, with any number of stages at any interval, not just\nliteral days:\n\n- `Schedule` absent → the quest simply inherits its cycle's window; earning and\n claiming follow the cycle's own `CanEarn`/`CanClaim`.\n- `Schedule` present → resolved via `ResolveQuestInstance`, which passes the\n **cycle's** resolved instance as the `parent` for `Relative`-mode windows —\n so a per-quest `Relative` schedule with `OffsetSecondsFromParentStart` is\n \"N seconds after this cycle instance started,\" letting one `Cyclic` cycle\n auto-repeat a whole staged sequence without hardcoded absolute dates.\n- `Availability.AccrueProgressWhenLocked` (default `false`) decides what happens **while**\n the cycle's window is open but the quest's own window is not: `false` means a\n locked stage accrues **zero** progress (a true lock — progress reported for\n its metric while locked is simply dropped for that quest); `true` means\n progress accrues the whole time the cycle is active, but the **reward claim**\n is still gated on the quest's own `CanClaim` — so you can pre-accrue \"Day 3\"\n progress while day 3 is still locked, and only the payout waits.\n\nEarning gate precedence for a cyclic quest, all of which must pass\n(`AddQuestProgress` internal helper): cycle `earningCycles` membership (cycle\nitself must be `CanEarn`, i.e. not `IsInPause`) → quest's own\n`IsQuestEarnable` (`AccrueProgressWhenLocked` bypasses this specific check) →\n`QuestGatesPass` (cycle `Gate` AND `Availability.Gate`) → prerequisites (only in\n`BlockProgressAndClaim` mode).\n\n---\n\n## Points track (\"Achievements\") — the Quest event-token\n\nThis is the mechanism the \"Achievements\" hint in the prompt refers to — it is\nreal and it is exactly the cycle's points track, not a separate module. Russian\ncomments in `Quest.cs` literally label it «Достижения» (Achievements).\n\n**How points get earned.** Each cyclic `QuestDefinition.Reward.PointsReward` (points,\nnot currency) is granted **only on claim** of that quest's own reward — via\n`ClaimQuestReward` / `ClaimQuestRewardsBatch`, in the **same atomic transaction**\nas the quest's `Reward` grant. It's `long`, defaults to `0`, and is ignored for\npermanent quests (`isPermanent` quests never touch the points track). A group\ncompletion (below) can **also** add points via its own `PointsReward`, on top of\nwhatever its member quests already contributed individually.\n\n**Where it's addressed.** The points track is backed by a standard\n`EventTokenType.Quest` event token (the same primitive TimedEvent/Leaderboard/\nCoopEvent/Season points tracks use), addressed at\n`EntityID = \"{cycleID}:{instanceKey}\"` where `instanceKey` comes from\n`ScheduleResolver`'s resolution of the **cycle's** schedule (`\"all\"` if the\ncycle has no resolvable instance). Because the instance key changes when the\ncycle's schedule rotates to a new window, **the points balance and claimed-milestone\nlist reset automatically on cycle rollover** — there is no explicit\n\"reset points\" step; it's a natural consequence of the address changing.\n\n**Where it lives in state.** `UserQuestState` does **not** carry the points\nbalance — it lives in `UserDataDocument.EventToken.Quest[entityID]`\n(`UserEventTokenProgress`: `Balance.Current`/`Balance.TotalEarned`,\n`Milestone.ClaimedIDs`). `GetUserQuestState` additionally projects a **read-only\nsnapshot** per cycle into `GetUserQuestStateResponse.PointsTracks[cycleID]`\n(`QuestPointsTrackView`) so the client doesn't have to know the composite key —\nthe TS SDK's `patchQuestPointsTracks` writes this into\n`client.data.user.state.EventToken.Quest[\"{cycleID}:{instanceKey}\"]` for you,\nand `client.data.user.getQuestPointsProgress(cycleID)` resolves the composite\nkey back out (`matchesBase`, `util/eventTokenIds.ts`) so you can look it up by\nplain `cycleID`.\n\n```ts\ninterface QuestPointsTrackView {\n CycleID: string;\n InstanceKey?: string | null;\n CycleStartUtc?: string | null;\n CycleEndUtc?: string | null; // source for a \"resets in\" timer\n PointsTotalEarned?: number | null; // lifetime points earned this cycle instance\n PointsCurrent?: number | null; // current balance (== TotalEarned; points are never spent)\n ClaimedPointMilestoneIDs?: string[] | null;\n}\n```\n\n**Milestones** (`QuestCycleDefinition.Milestones`, keyed by `MilestoneID`) are\nthe shared Core `MilestoneDefinition` primitive\n(`RequiredProgress`, `Rewards`, `BonusRewards`, `SeasonTierRewards`,\n`SortOrder`, `IsFeatured`) — see `_shared/MilestoneModels.ts`. Eligibility is\njudged **only** against `Balance.TotalEarned` on the points token (never\n`Current`, though for Quest the two happen to always be equal since points are\nonly ever granted, never spent) — `EventTokenService.ComputeMilestoneClaim`:\nfails with `\"Not enough earned. Have: X, need: Y.\"` if under threshold, or\n`\"Milestone already claimed.\"` if `MilestoneID` is already in `ClaimedIDs`.\nClaiming pushes the id into `ClaimedIDs` via a Mongo `$push` guarded by a\n`$nin` filter (OCC — a concurrent duplicate claim loses the race cleanly). The\nmilestone's reward itself runs through the shared `MilestoneRewardResolver`\n(same resolver Leaderboard/TimedEvent/CommunityChest/Referral use), which\napplies the title's progression-multiplier overlay\n(`cfg.Reward.MilestoneRewardMultiplier`) if configured — so the actual payout\ncan exceed the base `Rewards` grant; read it from the response, don't assume\nface value.\n\n**Caps** on points grants (`BuildPointsGrantContext`): **global** caps come from\n`QuestCycleDefinition.PointsToken` (an `EventTokenDefinition` — `DailyEarnCap`,\n`MaxBalance`, `MaxPerGrant`); **per-quest-source** caps/cooldown come from\n`QuestAvailability.Limits` (a `LimitSpec`, mapped as `DailyWeightCap` →\nper-source daily cap, `DailyCap` → per-source daily trigger count,\n`CooldownSeconds` → per-source cooldown). Per-source limits only apply in the\n**single** `ClaimQuestReward` path — the batch claim path\n(`ClaimQuestRewardsBatch`) only enforces the cycle's **global** `PointsToken`\ncaps against the **summed** batch amount per address, since per-source limits\ndon't make sense once amounts from multiple quests are merged into one token\noperation. If a cap fully exhausts the grant, `EventTokenService.ComputeGrant`\ncan reduce the amount to `0`, which surfaces as a failed points portion inside\nthe resource operation — always read granted amounts from the response, never\nassume the full `PointsReward` landed.\n\n---\n\n## Group-completion (grand reward) math\n\n`QuestCycleDefinition.GroupCompletions[completionID]` (`QuestGroupCompletionDefinition`):\n\n```ts\ninterface QuestGroupCompletionDefinition {\n CompletionID?: string;\n GroupID?: string; // must match QuestLinking.GroupID on member quests\n RequiredCompletedQuests?: number; // 0 = \"ALL quests in this group, per current config\"\n Gate?: SegmentGate; // ANDed with the cycle's Gate\n Reward?: ResourceGrant;\n PointsReward?: number; // additional points into the SAME cycle points track; 0 = none\n}\n```\n\n`ClaimGroupCompletionReward` computes eligibility **live**, at claim time, by\nscanning the **current** `QuestDefinitions.Quests` for every quest that (a)\nlists this `cycleID` in its `Linking.CycleIDs` and (b) has `Linking.GroupID` equal to the\ncompletion's `GroupID` — that's `totalGroupQuests`. Of those, it counts how many\nhave reached `Status === \"Completed\"` **or** `\"Claimed\"` in the player's current\ncycle state — that's `completedGroupQuests`. The required threshold is\n`RequiredCompletedQuests` if `> 0`, otherwise `totalGroupQuests` (i.e. every\ngroup quest currently in config). Failure modes:\n\n- `RequiredCompletedQuests` unset and the group is empty/misconfigured (no\n quests currently reference that `GroupID` in that cycle) →\n `\"No quests configured for this group\"` (required resolves to `0`, which is\n rejected outright — you can never claim an empty group).\n- `completedGroupQuests < required` → `\"Not enough completed quests for this\ngroup\"`.\n- Already in `cycle.ClaimedGroupCompletionIDs` → `\"Group completion already\nclaimed\"` (checked in-memory before the DB round-trip, then re-enforced by an\n `AnyEq`-negated Mongo filter for the actual OCC guard).\n- `completion.GroupID` blank/whitespace on the definition itself →\n `\"Group completion has no GroupID\"` (a config error, not a player error).\n\nBecause the scan is **live against current config**, removing a quest from the\ngroup (or from the cycle) between when a player completed it and when they\nclaim the group reward can change `totalGroupQuests`/`completedGroupQuests` —\nthere's no snapshot of \"the group as it was.\" The response echoes\n`CompletedGroupQuests` and `RequiredGroupQuests` (the resolved threshold, not\nthe raw config field) so the client can show \"3 / 3\" without recomputing\nanything.\n\n---\n\n## `AddQuestProgress` server-side rules\n\nFull request-to-mutation path (`QuestV2.AddQuestProgress` public entry point +\nthe internal shared helper), summarized because several rules only make sense\ntogether:\n\n1. `MetricID` required; must match at least one `ClientApi`-sourced objective\n in the **entire** quest catalog, or the call fails with `\"MetricID not\nallowed for ClientApi\"` before touching the database.\n2. `ProgressValue` (`long`) must be `>= 0`.\n3. If any matching objective declares `MaxProgressPerCall > 0`, the **raw**\n value is checked against the smallest such cap across all matches; exceeding\n it **bans the account** (see the objective section above) rather than\n clamping — this is a hard security control, not UX guidance.\n4. The (possibly `Sum`-clamped) value then fans out to **every** quest/objective\n pair across **every currently-earning cycle and every permanent quest**\n whose objective's `Source === \"ClientApi\"` and `MetricID` matches — one\n `addQuestProgress` call can move several quests (even across different\n cycles) simultaneously if they all listen to the same metric.\n5. Each matched quest only advances if it isn't already `Completed`/`Claimed`\n (`ApplyToQuestInstance` early-returns `false` otherwise) — so calling\n `addQuestProgress` for an action a player keeps performing after a quest is\n done is safe and a no-op for that quest.\n6. The response's `Updates[]` (`QuestProgressUpdate`) lists **only the\n quest/objective pairs that actually changed** this call — an objective whose\n `Sum` increment was clamped to `0` (already at `TargetValue`) or a locked\n quest that accrued nothing produces no entry.\n7. This whole path calls `RefreshQuestCycles` first (unless the internal helper\n is invoked with `ensureCyclesUpToDate: false`, which the public\n `AddQuestProgress` action does to avoid double-refreshing) — so cycle\n windows are always current before progress is evaluated.\n\n---\n\n## Idempotency, atomicity, batch limits\n\n- **Idempotency keys** (`ResourceService.ResolveRelatedEntityID`, stable ID\n patterns from `Quest.cs`): single quest claim →\n `\"{questID}\"` (permanent) or `\"{questID}_{cycleStartUtc:yyyyMMddHHmmss}\"`\n (cyclic — so the **same** quest claimed again after the cycle rolls to a new\n window is a distinct idempotency key, not a duplicate); milestone claim →\n `\"{cycleID}_{milestoneID}_{instanceKey}\"`; group completion →\n `\"{cycleID}_{completionID}_{cycleStartUtc:yyyyMMddHHmmss}\"`. You never\n construct these yourself — the TS SDK mints its own client-side\n `RelatedEntityID` (`quest_claim_…`, `milestone_claim_…`, `group_completion_…`,\n each suffixed with a fresh UUID) purely for its own request-level tracking;\n the **server-side** idempotency guarantee comes from the stable IDs above\n plus the OCC filter on each claim, not from the client's `RelatedEntityID`.\n- **Atomicity.** Every claim path (`ClaimQuestReward`, `ClaimMilestoneReward`,\n `ClaimGroupCompletionReward`, and both batch variants) runs the reward grant\n and the state-mutating patches (status → `Claimed`, milestone `$push`, group\n `$addToSet`) inside **one** `ResourceService.ApplyResourceOperationAtomicAsync`\n call with an `extraFilter` re-asserting the pre-claim condition (e.g. quest\n `Status == Completed`) — if the grant fails for any reason (insufficient\n server-side room, a concurrent claim already flipped the filter condition,\n etc.) the whole transaction rolls back; there is no partially-applied claim.\n- **Resources in batch responses.** For both `ClaimQuestRewardsBatch` and\n `ClaimMilestoneRewardsBatch`, all included items' rewards are merged into a\n **single** `ResourceBundle` (`BatchSupport.MergeBundles`) and charged/granted\n in one call — the merged `ResourceOperation` is attached to only the **first\n successful** `BatchItemResult.Data.Resources` in the returned array; every\n other successful item's `Data.Resources` is an **empty** `ResourceOperation`\n (`new ResourceOperation()`), not a duplicate of the shared one. Don't sum\n resources across batch items — read them once from wherever they landed (the\n TS SDK's `applyResourceOperation` is only ever called once, on the first\n `Resources` it finds, matching this).\n- **Batch size.** `BatchSupport.MaxBatchSize = 50`. Both\n `claimQuestRewardsBatch` and `claimMilestoneRewardsBatch` accumulate refs from\n your array only up to 50 (after deduping by `CycleID+QuestID` /\n `CycleID+MilestoneID`); anything past the 50th valid, deduped entry is\n **silently dropped** — it never appears in the result array at all, so a\n `results.length` shorter than your input isn't necessarily an error. Chunk\n larger sets yourself.\n- **Batch validity filtering happens before charging.** Each item is\n independently checked (mongo-safety, config existence, gates, schedule\n window, prerequisites, current `Status`) and rejected into a preset\n `BatchItemResult` **before** the shared resource operation runs; only\n surviving items contribute to the merged grant and the combined Mongo filter\n (`AND` of each item's own OCC filter). That combined filter means: if even\n one surviving item's condition is no longer true by the time the transaction\n actually commits (e.g. a race with another request), **the entire merged\n operation fails** and every surviving item in that batch call reports the\n same `apply.Error` — \"partial-aware\" describes the **pre-filtering** stage,\n not protection against a mid-flight race on the shared charge.\n- **Rate limit / lock.** The whole `QuestV2` function uses\n `RateLimitMilliseconds = 500` (per-IP endpoint throttle) and\n `LockDurationMilliseconds = 10000` (per-user-action Mongo transaction lock)\n inside `ClientRun.Execute` — both are backend-side controls independent of\n the TS SDK's own 600ms client-side throttle guard.\n"
8
+ "content": "# Quest data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nresolution rules, the points-track (\"Achievements\") plumbing, group-completion\nmath, and the server-side limits/idempotency rules. All of these are **strictly\ntyped in the SDK** — `QuestDefinitions` and every nested block (`QuestDefinition`\nand its `QuestIdentity`/`QuestLinking`/`QuestAvailability`/`QuestReward` blocks,\n`QuestCycleDefinition`, `QuestPhaseDefinition`, `QuestObjectiveDefinition`,\n`QuestGroupCompletionDefinition`, `QuestPresetRegistry`/`QuestPresetBindings`,\nthe shared `ScheduleSpec`/`SegmentGate`/`LimitSpec`/`MilestoneDefinition`/\n`EventTokenDefinition` blocks, …) are exported from `@idosgames/core`, so\n`getQuestDefinitions()` and `getSection<QuestDefinitions>(\"Quest\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight from\nthe backend JSON).\n\nBackend source of truth for everything below:\n`IDosGamesSDK/API/Client/v2/Quest/Quest.cs`,\n`IDosGamesSDK/API/Client/v2/Quest/Models/QuestDefinitions.cs`,\n`IDosGamesSDK/API/Client/v2/Quest/Models/UserQuestState.cs`,\n`IDosGamesSDK/API/Core/Scheduling/Services/ScheduleResolver.cs`,\n`IDosGamesSDK/API/Core/Event/Services/EventTokenService.cs`.\n\n## Contents\n\n- [Player state](#player-state) — what `getUserQuestState()` returns\n- [Config: QuestDefinitions](#config-questdefinitions) — what `getQuestDefinitions()` returns\n- [QuestCycleDefinition](#questcycledefinition)\n- [QuestDefinition](#questdefinition)\n- [Presets](#presets--authoring-n-days--m-tasks-without-nm-copies) — authoring N days × M tasks without N×M copies\n- [QuestObjectiveDefinition + progress aggregation](#questobjectivedefinition--progress-aggregation)\n- [Prerequisites (`RequiredQuestIDs`)](#prerequisites-requiredquestids)\n- [Cycle schedule resolution](#cycle-schedule-resolution)\n- [Per-quest schedule (\"staged unlock\" / Achievements)](#per-quest-schedule-staged-unlock--achievements)\n- [Points track (\"Achievements\") — the Quest event-token](#points-track-achievements--the-quest-event-token)\n- [Group-completion (grand reward) math](#group-completion-grand-reward-math)\n- [`AddQuestProgress` server-side rules](#addquestprogress-server-side-rules)\n- [Idempotency, atomicity, batch limits](#idempotency-atomicity-batch-limits)\n\n---\n\n## Player state\n\nReturned by `getUserQuestState()` as `{ State, PointsTracks }` and cached at\n`client.data.user.state?.Quest` (progress) + `client.data.user.state?.EventToken?.Quest`\n(points track — see below). Hand-written interfaces (not `z.infer`) because the\ncache-patch methods mutate these objects in place.\n\n```ts\ninterface UserQuestState {\n Cycles?: Record<string, UserQuestCycleState>; // key = CycleID\n PermanentQuests?: Record<string, UserQuestProgress>; // key = QuestID\n LastUpdatedUtc?: string;\n}\n\ninterface UserQuestCycleState {\n CycleID?: string;\n CycleStartUtc?: string; // current window start, UTC\n CycleEndUtc?: string; // current window end, UTC\n Quests?: Record<string, UserQuestProgress>; // key = QuestID, THIS window only\n ClaimedGroupCompletionIDs?: string[]; // CompletionIDs already claimed this window\n}\n\ninterface UserQuestProgress {\n QuestID: string;\n Status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n ActivatedAtUtc?: string | null;\n CompletedAtUtc?: string | null;\n ClaimedAtUtc?: string | null;\n Objectives?: Record<string, UserQuestObjectiveProgress>; // key = ObjectiveID\n}\n\ninterface UserQuestObjectiveProgress {\n ObjectiveID: string;\n CurrentValue: number;\n Completed: boolean;\n CompletedAtUtc?: string | null;\n}\n```\n\n**Lazy initialization** (`Quest.cs` `RefreshQuestCycles` / `CreateQuestProgressFromDefinition`):\na quest's `UserQuestProgress` (and each objective's `UserQuestObjectiveProgress`)\nis created only the first time progress is reported for it — the server does\n**not** pre-populate every configured quest/objective with zeros. A quest absent\nfrom `Cycles[cycleID].Quests` (or `PermanentQuests`) simply has zero progress on\nevery objective; render it as `\"Active\"`, not as an error or \"unknown\" state.\n`Expired` is only ever set for cyclic quests (never for permanent quests) and\nonly via the same lazy path — in practice you will see `Active` → `Completed` →\n`Claimed` for anything you've touched; a truly stale quest from a rolled-over\ncycle is simply absent (the whole cycle bucket gets replaced on rollover, see\nbelow), not flagged `Expired` in current code paths.\n\n---\n\n## Config: QuestDefinitions\n\nReturned by `getQuestDefinitions()`; cached via\n`client.data.config.getSection<QuestDefinitions>(\"Quest\")`.\n\n```ts\ninterface QuestDefinitions {\n Cycles?: Record<string, QuestCycleDefinition>; // key = CycleID\n Quests?: Record<string, QuestDefinition>; // key = QuestID\n Presets?: QuestPresetRegistry; // reusable blocks, one registry per QuestDefinition block\n}\n```\n\n**Quests arrive already assembled.** The config is _authored_ compactly — a field left unset on a\nquest comes from the preset bound to that block — but the backend resolves it once when it\nmaterializes the title config, so what `getQuestDefinitions()` returns already has every quest's\nblocks filled in. `Presets` rides along for editors; a game client never merges anything.\n\nAssembled is not the same as flattened: the **shape** stays blocked. A quest's name is at\n`Identity.DisplayName`, its cycles at `Linking.CycleIDs`, its window at `Availability.Schedule`,\nits payout at `Reward.Grant`.\n\nA quest is **permanent** iff its `Linking.CycleIDs` is null/empty; otherwise it is\n**cyclic** and belongs to every cycle listed there (a quest can appear\nin more than one cycle definition, each with independent progress/claim state).\nCycle IDs and quest/objective IDs must all be Mongo-safe (no `.` or `$`) —\nthe server rejects unsafe keys outright (`\"Invalid CycleID (mongo-unsafe): …\"`,\n`\"QuestID is mongo-unsafe\"`, etc.); this only matters if you let players or\nremote config drive raw ids into these fields.\n\n---\n\n## QuestCycleDefinition\n\n`Quest.cs` / `QuestDefinitions.cs`. One recurring or one-off \"board\" (dailies,\nweeklies, a scheduled event window, …). Quests do **not** get listed here —\neach `QuestDefinition` points back at the cycle via `Linking.CycleIDs`.\n\n```ts\ninterface QuestCycleDefinition {\n CycleID?: string;\n DisplayName?: string;\n Schedule?: ScheduleSpec; // cycle window/reset — see below\n Milestones?: Record<string, MilestoneDefinition>; // points-track rungs, key = MilestoneID\n Phases?: Record<string, QuestPhaseDefinition>; // chain phases, only for Schedule.Mode = \"Chained\"\n Presets?: { Milestones?: PresetBinding }; // cycle-level preset wiring (Core/Presets)\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n Gate?: SegmentGate; // audience gate for the whole cycle; ANDed with each quest's own Gate\n PointsToken?: EventTokenDefinition; // global caps/burn for the points track (see below)\n GroupCompletions?: Record<string, QuestGroupCompletionDefinition>; // key = CompletionID\n}\n```\n\nBackend default when a cycle is authored without an explicit `Schedule`:\n`Mode: \"Cyclic\"`, `Cyclic: {}` (i.e. daily calendar reset) —\n`QuestCycleDefinition.Schedule` in `QuestDefinitions.cs`.\n\n---\n\n## QuestDefinition\n\nOnly the ID lives at the root; everything else is a named block, exactly like\n`CharacterDefinition` (`Identity` / `Classification` / `Unlock` / `Stats` / …).\n\n```ts\ninterface QuestDefinition {\n QuestID?: string;\n Identity?: QuestIdentity;\n Linking?: QuestLinking;\n Availability?: QuestAvailability;\n Objectives?: Record<string, QuestObjectiveDefinition>; // key = ObjectiveID; ALL must complete\n Reward?: QuestReward;\n Presets?: QuestPresetBindings; // one binding per block — see Presets\n}\n\n/** Display part — analogous to CharacterIdentity. */\ninterface QuestIdentity {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number; // lower = earlier in UI; default 0\n AssetPaths?: Record<string, string>; // task icon and other client assets\n CustomParams?: Record<string, string>; // passed to the client untouched\n}\n\n/** Links — analogous to CharacterClassification. */\ninterface QuestLinking {\n CycleIDs?: string[]; // null/empty => permanent; else one entry per cycle it appears in\n GroupID?: string; // plain label: UI sections + QuestGroupCompletionDefinition matching\n PhaseIDs?: string[]; // chain phases this quest lives in; empty = all\n RequiredQuestIDs?: string[]; // prerequisite QuestIDs — see below\n PrerequisiteMode?: \"BlockProgressAndClaim\" | \"BlockClaimOnly\"; // default BlockProgressAndClaim\n}\n\n/** Access rules — analogous to CharacterUnlock. */\ninterface QuestAvailability {\n Schedule?: ScheduleSpec; // per-quest unlock window; unset = the cycle's window (see below)\n AccrueProgressWhenLocked?: boolean; // default false — see per-quest schedule section\n Gate?: SegmentGate; // ANDed with the cycle's Gate\n Limits?: LimitSpec; // per-source caps on POINTS grants only (not on objective progress)\n}\n\n/** Claim payout: the grant plus points into the cycle track. */\ninterface QuestReward {\n Grant?: ResourceGrant; // claimed via claimQuestReward\n PointsReward?: number; // points into the cycle's track on claim; ignored for permanent quests\n}\n```\n\nThere is **no group entity.** `Linking.GroupID` is a plain string: it groups quests into UI\nsections and it is what `QuestGroupCompletionDefinition` matches on. Nothing has to declare it,\nand nothing inherits through it.\n\nIn the **stored** config every block, and every field inside it, is optional in the strong sense —\nabsent means \"take it from the preset bound to this block\" (see\n[Presets](#presets--authoring-n-days--m-tasks-without-nm-copies)). By the time this reaches a\nclient the backend has already assembled them.\n\n`PrerequisiteMode` (`QuestDefinitions.cs` comment, verbatim intent):\n\n| Mode | Effect on `RequiredQuestIDs` |\n| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `BlockProgressAndClaim` (default) | The quest does not start accruing progress at all until every listed prerequisite reaches `Completed`/`Claimed`; consequently it also can't be claimed. |\n| `BlockClaimOnly` | Progress accrues immediately; only the final reward claim is blocked until prerequisites are met. |\n\nA prerequisite is looked up \"where its own progress lives\": permanent →\n`PermanentQuests`; cyclic → the same `cycleID` if the prerequisite also belongs\nto it, otherwise the prerequisite's own first `Linking.CycleIDs` entry\n(`ArePrerequisitesMet`, `Quest.cs`). Empty/null `RequiredQuestIDs` = no gating.\n\n---\n\n## Chains — a cycle that runs phases one after another\n\nSet the cycle's `Schedule.Mode` to `\"Chained\"` and fill `Phases`. The chain starts at\n`Schedule.Chain.AnchorUtc`, plays its phases in `Order`, then repeats (`MaxCycles`, pauses).\n\n```ts\ninterface QuestPhaseDefinition {\n PhaseID?: string; // unique within the chain; referenced by QuestLinking.PhaseIDs\n Order?: number; // position within one full pass (0, 1, 2...)\n DurationSec?: number; // how long the phase stays open\n ClaimGraceHours?: number; // extra claim window after it ends\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n Gate?: SegmentGate; // AND-ed with cycle gate and quest gate\n Milestones?: Record<string, MilestoneDefinition>; // null = the cycle's ladder is used\n Presets?: { Milestones?: PresetBinding };\n PointsToken?: EventTokenDefinition; // null = the cycle's token\n}\n```\n\nThree rules worth knowing before designing one:\n\n- **Every phase owns its progress.** The points-track address includes the instance key, and for\n a phase that key is `chain:{cycleIndex}:{phaseID}`. So week 2 starts from zero points with its\n own claimed-milestone list — it never inherits week 1. The cycle's quest state resets at a phase\n boundary exactly like it resets at midnight for a `Daily` cycle.\n- **Unset phase content falls back to the cycle.** Eight identical weeks are eight phases with\n only `Order`/`DurationSec` filled — not eight copies of the milestone ladder. The exception is an\n _empty_ (not absent) `Milestones` object: that explicitly means \"no milestones in this phase\".\n- **A `Chained` cycle with no phases never activates.** There is nothing to resolve, so the whole\n cycle stays closed and its quests never progress.\n\nBind a quest to specific phases with `Linking.PhaseIDs` (empty = every phase). It gates\nboth progress and claiming — a week-2 quest cannot be claimed during week 1, in single and batch\nclaims alike. `PhaseIDs` is the direct way to say \"this quest belongs to week two\"; the quest's own\n`Availability.Schedule` with a `Relative` window stays for staged unlocking _within_ one phase\n(\"Day N\").\n\n`GetUserQuestState` reports the live phase on each track: `PointsTracks[cycleID].PhaseID` and\n`.CycleIndex` (both absent/0 for a plain cycle) alongside `CycleStartUtc`/`CycleEndUtc` of that\nphase — enough to render \"Week 2 of 8\" and a countdown.\n\n### Milestone presets\n\n`QuestDefinitions.Presets.Milestones` is a registry of reusable `MilestoneSet`s keyed by PresetID —\nthe one preset block that belongs to cycles and phases rather than to quests. A cycle or a phase\nreferences one through `Presets.Milestones` (`PresetBinding`): the preset is the base, the inline\n`Milestones` dictionary overrides or adds by MilestoneID, and `Remove` drops keys. No PresetID ⇒\ninline only. An unknown PresetID silently falls back to inline — it never wipes the entity's own\nladder. Everything else about presets is in the next section.\n\n---\n\n## Presets — authoring N days × M tasks without N×M copies\n\nA seven-day event of six tasks a day is 42 quests that differ in three numbers. One mechanism\nexists so the config says that once instead of 42 times, and it is the **same one Character\nuses**: a registry of reusable blocks plus a binding per block. There is no second mechanism —\nno group entity, no chassis, no inheritance chain. It is **authoring-side only**: the backend\nresolves it at config load and everything downstream sees ordinary assembled quests.\n\n**The one rule:** _unset = take it from the preset, set = final._ A field that is absent takes its\nvalue from the preset bound to that block; a field that is present — **including `0`, `false` and\n`[]`** — wins and is never overwritten. That asymmetry is deliberate: \"this quest gives no points\"\n(`Reward.PointsReward: 0`) has to survive against a preset that grants 30.\n\n```ts\n/** Registry: one dictionary per block, each mirroring the same-named QuestDefinition block. */\ninterface QuestPresetRegistry {\n Milestones?: Record<string, MilestoneSet>; // cycles and chain phases only\n Linking?: Record<string, QuestLinking>;\n Availability?: Record<string, QuestAvailability>;\n Reward?: Record<string, QuestReward>;\n Objectives?: Record<string, Record<string, QuestObjectiveDefinition>>; // inner key = ObjectiveID\n}\n\n/** Wiring: one binding per block, exactly like CharacterDefinition.Presets. */\ninterface QuestPresetBindings {\n Milestones?: PresetBinding; // on a cycle / phase, not on a quest\n Linking?: PresetBinding;\n Availability?: PresetBinding;\n Reward?: PresetBinding;\n Objectives?: PresetBinding; // merges by ObjectiveID; `Remove` drops preset entries\n}\n```\n\n**Bindings are independent.** Take the schedule from one preset, the reward from another, and\nwrite the objectives inline — the blocks don't know about each other. Precedence inside one\nblock is just two layers:\n\n```\nquest's own field → the preset bound to that block → engine default\n```\n\n**`Identity` has no preset on purpose.** A quest's name and sort order are unique to it, and\n`Description` — the only field that is ever shared — is displayed by no client, so a registry for\nthis block added a binding to every quest and carried nothing. Write Identity inline.\n\nSingle-object blocks (`Linking` / `Availability` / `Reward`) merge **field by field**. `Objectives` merges **by ObjectiveID**, and inside a matched objective the same\nunset-takes-from-preset rule applies — that is the piece that pays for itself: the preset says\n_how_ an objective advances, the quest restates only what differs.\n\n```jsonc\n// preset: how \"make N moves\" works — written once\n\"Presets\": { \"Objectives\": { \"moves\": {\n \"task\": { \"Source\": \"SystemEvent\", \"TargetValue\": 15,\n \"Triggers\": [{ \"SourceType\": \"BoardTileLanding\" }] } } } }\n\n// day 5's quest: name and target are all that is unique\n\"Quests\": { \"e7_d5_moves\": {\n \"Identity\": { \"DisplayName\": \"Day 5. Make 35 moves\", \"SortOrder\": 501 },\n \"Presets\": {\n \"Linking\": { \"PresetID\": \"e7\" }, // cycle + group label, shared by all 42\n \"Availability\": { \"PresetID\": \"e7_d5\" }, // \"opens 4 days after the event starts\"\n \"Reward\": { \"PresetID\": \"e7_d5\" }, // day-5 payout, shared by that day's 6 tasks\n \"Objectives\": { \"PresetID\": \"moves\" }\n },\n \"Objectives\": { \"task\": { \"TargetValue\": 35 } } // triggers survive — only the number changes\n}}\n```\n\nThree things that bite if you don't know them:\n\n- **The dictionary key is the ID.** A quest or objective written without `QuestID` /\n `ObjectiveID` takes it from its key. In the compact form it is easy to omit, and an objective\n with no ID used to be skipped silently — the quest looked configured and never moved.\n- **An unknown PresetID falls back to inline**, it never wipes the block. A typo therefore shows\n up as a quest with a missing window or a missing reward, not as an error at load.\n- **`Remove` on `Presets.Objectives`** is the only way to take a preset objective away for a\n single quest.\n\n---\n\n## QuestObjectiveDefinition + progress aggregation\n\n```ts\ninterface QuestObjectiveDefinition {\n ObjectiveID?: string;\n Source?: \"ClientApi\" | \"ServerApi\" | \"SystemEvent\"; // what advances it; default ClientApi\n MaxProgressPerCall?: number; // ClientApi BAN threshold (not a clamp); 0/absent = no check\n MetricID?: string; // ClientApi/ServerApi only: the metric key reported against\n Triggers?: TriggerSource[]; // SystemEvent only: in-game events that advance it\n TargetValue?: number; // default 1; required value to complete\n AggregationMethod?: string; // \"Sum\" | \"Maximum\" | \"Minimum\" | \"Last\"; default \"Sum\"\n}\n```\n\n`Source` selects **which field is read** — they are mutually exclusive:\n\n| Source | Advanced by | Field read |\n| ------------- | ----------------------------------------------------------------- | ---------- |\n| `ClientApi` | the game calling `addQuestProgress(MetricID, v)` | `MetricID` |\n| `ServerApi` | a CloudCode script calling `server.AddQuestProgress(MetricID, v)` | `MetricID` |\n| `SystemEvent` | the backend itself, on in-game events | `Triggers` |\n\n`addQuestProgress` reaches **only** `ClientApi` objectives; a `MetricID` with no\nmatching `ClientApi` objective anywhere in the catalog is rejected outright\n(`\"MetricID not allowed for ClientApi\"`).\n\n### `Triggers` — SystemEvent objectives\n\n`Triggers` is the shared `TriggerSource` used by `EventContent.TokenSources`\n(TimedEvent) and `LeaderboardDefinition.ScoreSources`: an event type plus\nfilters, with `BaseWeight` as the progress step per fire. The list is OR-ed\n(first match wins). Empty/absent ⇒ the objective never advances.\n\nThe backend emits these event types into quests — anything else in\n`EventTokenSourceType` never reaches a quest (use `ClientApi` for\nclient-observed actions like watching an ad):\n\n`BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`, `BoardBuild`,\n`BoardStageComplete`, `BoardSpecialComplete` (GameLoop) · `StorePurchase`\n(`Store.Purchase` / `PurchaseBatch`, multiplier = purchase count) ·\n`MarketplaceSell` / `MarketplaceBuy` (settlement, **acting player only**) ·\n`QuestComplete` (`ClaimQuestReward`, for meta-quests) · `DailyLogin` (first login of a UTC day —\ndeduped at login, so ten re-entries in one evening count as one day) · `CurrencySpent`\n(`ResourceService`, on the applied consume; multiplier = **amount spent**) · `LootboxOpened`\n(multiplier = boxes opened in the call) · `LeaderboardRankReward` (fired when a rank reward is\nactually claimed, not while the standing changes) · `IapPurchase` (`PurchaseV2`, after the receipt\nis verified and the goods granted; multiplier = units granted — **also fires on subscription\nauto-renewals** from the store callback, tagged `Renewal: \"true\"`) · `CryptoDeposit` / `CryptoWithdraw`\n(deposit credited / withdrawal **confirmed on chain** — not on the request, which may never land;\nmultiplier = 1 operation) · `CryptoSpent` (crypto consumed in-game; multiplier = amount) ·\n`CurrencyEarned` / `CryptoEarned` (`ResourceService`, on the applied **grant**, premium tiers\nincluded; multiplier = **amount granted**).\n\nTwo of these carry an _amount_ in the multiplier rather than a count, which makes\n`ScaleWithRollMultiplier` the switch between two different goals:\n\n| Source | `true` | `false` |\n| ---------------- | ---------------------------------------------- | --------------------------- |\n| `CurrencySpent` | \"spend 100 coins\" | \"make 100 separate spends\" |\n| `CryptoSpent` | \"spend 100 tokens\" | \"make 100 separate spends\" |\n| `CurrencyEarned` | \"earn 1000 coins\" | \"receive coins 1000 times\" |\n| `CryptoEarned` | \"earn 100 tokens\" | \"receive tokens 100 times\" |\n| `LootboxOpened` | \"open 15 chests\" (one call of 15 counts fully) | \"open a chest 15 times\" |\n| `IapPurchase` | \"buy 5 units\" (a x5 pack counts fully) | \"make 5 separate purchases\" |\n\nSoft currency, crypto and real money are three **separate** sources on purpose: a goal like\n\"spend 100\" must not be closeable by coins one day and by tokens or dollars the next. If a title\nstores crypto in minimal (wei-like) units, set `ScaleWithRollMultiplier: false` on `CryptoSpent`\nand count operations — the amount would otherwise be astronomically large.\n\n`Params` filters the matcher actually checks: `StorePurchase` → `OfferID`;\n`QuestComplete` → `QuestID`, `CycleID`; `Marketplace*` → `CatalogID`, `ItemID`,\n`OfferType`; `CustomAction` → `ActionName`; `CurrencySpent` → `CurrencyID`;\n`LootboxOpened` → `LootboxID`; `IapPurchase` → `ProductID`, `Store`, `Renewal`\n(`\"true\"` = subscription auto-renewal, `\"false\"` = the player bought it by hand; omit to count\nboth — money was paid either way);\n`CryptoDeposit` / `CryptoWithdraw` → `CurrencyID`, `NetworkID`; `CryptoSpent` → `CurrencyID`;\n`CurrencyEarned` / `CryptoEarned` → `CurrencyID`, `Origin`\n(`\"Gameplay\"` = only what the game paid out, `\"RewardClaim\"` = only quest/milestone/rank/season/daily\npayouts, omit to count both — a goal like \"earn 1000 coins\" is otherwise partly closed by other\nquests' rewards);\n`LeaderboardRankReward` → `LeaderboardID`, `Rank`\n(exact match — \"first place\" is `Rank: \"1\"`; for \"top 3\" declare three sources or omit `Rank`).\nAny other key is stored but ignored.\n\nSet `ScaleWithRollMultiplier: false` on a quest trigger unless you _want_ the\nboard's roll multiplier to inflate the step — otherwise \"win 3 raids\" closes on\na single x3 raid.\n\n`SystemEvent` progress is applied **after** the handler succeeds and returns on\nthat response's envelope as `QuestProgress: QuestProgressUpdate[]` (absent when\nnothing moved). The guarantee is at-most-once: the game action is already\ncommitted, so a failure here loses the event rather than rolling the action back.\n\nAggregation (`ApplyAggregation`, `Quest.cs`), given the objective's current\n`CurrentValue` and the incoming call value:\n\n| Method | New value |\n| ------------------------ | ---------------------------------------------------------------------------------------- |\n| `Sum` (default) | `current + incoming`, clamped to `long.MaxValue` on overflow; `incoming <= 0` is a no-op |\n| `Maximum` | `max(current, incoming)` |\n| `Minimum` | `incoming` if `current == 0`, else `min(current, incoming)` |\n| `Last` (or unrecognized) | `incoming` (last-write-wins) |\n\nAfter aggregation, if `TargetValue > 0` the new value is clamped to\n`TargetValue` (progress bars never overshoot 100%); if `TargetValue <= 0` there\nis no cap. An objective is marked `Completed` once\n`(TargetValue <= 0 && newValue > 0) || newValue >= TargetValue`. A quest becomes\n`\"Completed\"` once **every** objective the player has a progress record for is\n`Completed` **and** every objective in the definition has a progress record —\ni.e. an objective with zero recorded progress blocks completion (it's absent\nfrom the player's `Objectives` map, so the `All(...)` check in\n`EnsureQuestObjectivesAndCompletion` fails for it).\n\n`MaxProgressPerCall` guards two different things depending on\n`AggregationMethod`:\n\n- If **any** matching `ClientApi` objective for the `MetricID` has\n `MaxProgressPerCall > 0`, the server compares the **raw** `ProgressValue` you\n sent against the (minimum across matches) cap **before** any clamping. If the\n raw value exceeds it, the call is rejected **and the player is banned**\n (`service.BanUser(...)`, fire-and-forget) with error `\"User banned: Value\nexceeds MaxValuePerCall\"`. This is a hard anti-abuse trip-wire, not a soft\n clamp — never let client code send inflated values \"to be safe.\"\n- Only for objectives using `Sum` aggregation is the value additionally\n clamped to `MaxProgressPerCall` before summing (defense in depth; irrelevant\n once the ban-check above has already passed, since raw ⇐ cap by that point).\n\n---\n\n## Prerequisites (`RequiredQuestIDs`)\n\nSee the `PrerequisiteMode` table above. Enforcement points in `Quest.cs`:\n\n- **Progress accrual** (`AddQuestProgress` internal helper): for permanent\n quests and for each cycle a cyclic quest belongs to, prerequisites are\n checked (only in `BlockProgressAndClaim` mode) before the quest's\n `UserQuestProgress` is even created/updated for that call.\n- **Claim** (`ClaimQuestReward` / batch): `ArePrerequisitesMet(...)` is checked\n unconditionally (both modes gate the claim) — error\n `\"Prerequisite quests are not completed\"`.\n\n---\n\n## Cycle schedule resolution\n\n`QuestCycleDefinition.Schedule` is a `ScheduleSpec` (`_shared/ScheduleModels.ts`\n/ `Core/Scheduling`), the same primitive every other module uses. For Quest,\n`RefreshQuestCycles` resolves it via `ScheduleResolver.ResolveActive(...)`\ninto a `[CycleStartUtc, CycleEndUtc)` window per the active `Mode`:\n\n- **`Cyclic`** (the practical default for dailies/weeklies): `Cyclic.Reset` picks\n the calendar cadence — `Hourly`/`Daily`/`Weekly` (always **Monday** start)/`Monthly`\n (always the **1st**)/`Yearly` are calendar-aligned in **UTC**, reset time is\n **always 00:00:00 UTC** and is not configurable. `FixedInterval` instead repeats\n every `Cyclic.IntervalSeconds` seconds from `Cyclic.AnchorUtc` (default anchor\n `2026-01-01T00:00:00Z`, default interval 86400s if unset/≤0); an optional\n `PauseBetweenCyclesSec` inserts a dead gap after each active window during\n which `CanEarn` is `false` (`IsInPause = true`) but the window has still\n technically \"ended\" — new progress does not accrue during the pause, though\n already-completed quests remain claimable (claims are never earn-gated).\n- **`Scheduled`**: one fixed `[StartUtc, EndUtc]` window; `ClaimGraceHours`\n extends claimability past `EndUtc` without extending earning (unless\n `AllowEarningAfterEnd` is set).\n- **`AlwaysOn`**: always active, no end.\n- Any other/inactive resolution (e.g. `Triggered`, or `spec.IsActive === false`)\n makes `ComputeCycleWindowUtc` **degrade to a plain UTC calendar day**\n `[today 00:00, tomorrow 00:00)` as a fallback — don't configure `Triggered` on\n a quest cycle expecting anything else.\n\n**Rollover behavior** (`RefreshQuestCycles`, called automatically by\n`getUserQuestState({autoRefreshCycles: true})` — the default — and before every\nmutating Quest action): when the resolved `[start, end)` no longer matches the\nstored `CycleStartUtc`/`CycleEndUtc`, the entire `UserQuestCycleState` for that\ncycle is **replaced with a brand-new, empty one** (`Quests: {}`,\n`ClaimedGroupCompletionIDs` reset) — there is no partial carry-over of\nin-progress quests into the new window. Cycles removed from config entirely are\ndeleted from the player's state on the next refresh. If the window has **not**\nrolled over, the refresh instead walks the player's **existing** quest progress\nrecords (only ones already started) and re-evaluates `Completed` status against\ncurrent config — it does not add new objectives to already-tracked quests.\n\n---\n\n## Per-quest schedule (\"staged unlock\" / Achievements)\n\n`QuestAvailability.Schedule` is an **independent, optional** `ScheduleSpec` layered\non top of the cycle's own schedule — this is how \"Day 2 unlocks 24h after Day 1\"\nor an \"Achievements\" track with a `ScheduleSpec`-driven unlock (rather than a\nliteral day-count) is built, with any number of stages at any interval, not just\nliteral days:\n\n- `Schedule` absent → the quest simply inherits its cycle's window; earning and\n claiming follow the cycle's own `CanEarn`/`CanClaim`.\n- `Schedule` present → resolved via `ResolveQuestInstance`, which passes the\n **cycle's** resolved instance as the `parent` for `Relative`-mode windows —\n so a per-quest `Relative` schedule with `OffsetSecondsFromParentStart` is\n \"N seconds after this cycle instance started,\" letting one `Cyclic` cycle\n auto-repeat a whole staged sequence without hardcoded absolute dates.\n- `Availability.AccrueProgressWhenLocked` (default `false`) decides what happens **while**\n the cycle's window is open but the quest's own window is not: `false` means a\n locked stage accrues **zero** progress (a true lock — progress reported for\n its metric while locked is simply dropped for that quest); `true` means\n progress accrues the whole time the cycle is active, but the **reward claim**\n is still gated on the quest's own `CanClaim` — so you can pre-accrue \"Day 3\"\n progress while day 3 is still locked, and only the payout waits.\n\nEarning gate precedence for a cyclic quest, all of which must pass\n(`AddQuestProgress` internal helper): cycle `earningCycles` membership (cycle\nitself must be `CanEarn`, i.e. not `IsInPause`) → quest's own\n`IsQuestEarnable` (`AccrueProgressWhenLocked` bypasses this specific check) →\n`QuestGatesPass` (cycle `Gate` AND `Availability.Gate`) → prerequisites (only in\n`BlockProgressAndClaim` mode).\n\n---\n\n## Points track (\"Achievements\") — the Quest event-token\n\nThis is the mechanism the \"Achievements\" hint in the prompt refers to — it is\nreal and it is exactly the cycle's points track, not a separate module. Russian\ncomments in `Quest.cs` literally label it «Достижения» (Achievements).\n\n**How points get earned.** Each cyclic `QuestDefinition.Reward.PointsReward` (points,\nnot currency) is granted **only on claim** of that quest's own reward — via\n`ClaimQuestReward` / `ClaimQuestRewardsBatch`, in the **same atomic transaction**\nas the quest's `Reward` grant. It's `long`, defaults to `0`, and is ignored for\npermanent quests (`isPermanent` quests never touch the points track). A group\ncompletion (below) can **also** add points via its own `PointsReward`, on top of\nwhatever its member quests already contributed individually.\n\n**Where it's addressed.** The points track is backed by a standard\n`EventTokenType.Quest` event token (the same primitive TimedEvent/Leaderboard/\nCoopEvent/Season points tracks use), addressed at\n`EntityID = \"{cycleID}:{instanceKey}\"` where `instanceKey` comes from\n`ScheduleResolver`'s resolution of the **cycle's** schedule (`\"all\"` if the\ncycle has no resolvable instance). Because the instance key changes when the\ncycle's schedule rotates to a new window, **the points balance and claimed-milestone\nlist reset automatically on cycle rollover** — there is no explicit\n\"reset points\" step; it's a natural consequence of the address changing.\n\n**Where it lives in state.** `UserQuestState` does **not** carry the points\nbalance — it lives in `UserDataDocument.EventToken.Quest[entityID]`\n(`UserEventTokenProgress`: `Balance.Current`/`Balance.TotalEarned`,\n`Milestone.ClaimedIDs`). `GetUserQuestState` additionally projects a **read-only\nsnapshot** per cycle into `GetUserQuestStateResponse.PointsTracks[cycleID]`\n(`QuestPointsTrackView`) so the client doesn't have to know the composite key —\nthe TS SDK's `patchQuestPointsTracks` writes this into\n`client.data.user.state.EventToken.Quest[\"{cycleID}:{instanceKey}\"]` for you,\nand `client.data.user.getQuestPointsProgress(cycleID)` resolves the composite\nkey back out (`matchesBase`, `util/eventTokenIds.ts`) so you can look it up by\nplain `cycleID`.\n\n```ts\ninterface QuestPointsTrackView {\n CycleID: string;\n InstanceKey?: string | null;\n CycleStartUtc?: string | null;\n CycleEndUtc?: string | null; // source for a \"resets in\" timer\n PointsTotalEarned?: number | null; // lifetime points earned this cycle instance\n PointsCurrent?: number | null; // current balance (== TotalEarned; points are never spent)\n ClaimedPointMilestoneIDs?: string[] | null;\n}\n```\n\n**Milestones** (`QuestCycleDefinition.Milestones`, keyed by `MilestoneID`) are\nthe shared Core `MilestoneDefinition` primitive\n(`RequiredProgress`, `Rewards`, `BonusRewards`, `SeasonTierRewards`,\n`SortOrder`, `IsFeatured`) — see `_shared/MilestoneModels.ts`. Eligibility is\njudged **only** against `Balance.TotalEarned` on the points token (never\n`Current`, though for Quest the two happen to always be equal since points are\nonly ever granted, never spent) — `EventTokenService.ComputeMilestoneClaim`:\nfails with `\"Not enough earned. Have: X, need: Y.\"` if under threshold, or\n`\"Milestone already claimed.\"` if `MilestoneID` is already in `ClaimedIDs`.\nClaiming pushes the id into `ClaimedIDs` via a Mongo `$push` guarded by a\n`$nin` filter (OCC — a concurrent duplicate claim loses the race cleanly). The\nmilestone's reward itself runs through the shared `MilestoneRewardResolver`\n(same resolver Leaderboard/TimedEvent/CommunityChest/Referral use), which\napplies the title's progression-multiplier overlay\n(`cfg.Reward.MilestoneRewardMultiplier`) if configured — so the actual payout\ncan exceed the base `Rewards` grant; read it from the response, don't assume\nface value.\n\n**Caps** on points grants (`BuildPointsGrantContext`): **global** caps come from\n`QuestCycleDefinition.PointsToken` (an `EventTokenDefinition` — `DailyEarnCap`,\n`MaxBalance`, `MaxPerGrant`); **per-quest-source** caps/cooldown come from\n`QuestAvailability.Limits` (a `LimitSpec`, mapped as `DailyWeightCap` →\nper-source daily cap, `DailyCap` → per-source daily trigger count,\n`CooldownSeconds` → per-source cooldown). Per-source limits only apply in the\n**single** `ClaimQuestReward` path — the batch claim path\n(`ClaimQuestRewardsBatch`) only enforces the cycle's **global** `PointsToken`\ncaps against the **summed** batch amount per address, since per-source limits\ndon't make sense once amounts from multiple quests are merged into one token\noperation. If a cap fully exhausts the grant, `EventTokenService.ComputeGrant`\ncan reduce the amount to `0`, which surfaces as a failed points portion inside\nthe resource operation — always read granted amounts from the response, never\nassume the full `PointsReward` landed.\n\n---\n\n## Group-completion (grand reward) math\n\n`QuestCycleDefinition.GroupCompletions[completionID]` (`QuestGroupCompletionDefinition`):\n\n```ts\ninterface QuestGroupCompletionDefinition {\n CompletionID?: string;\n GroupID?: string; // must match QuestLinking.GroupID on member quests\n RequiredCompletedQuests?: number; // 0 = \"ALL quests in this group, per current config\"\n Gate?: SegmentGate; // ANDed with the cycle's Gate\n Reward?: ResourceGrant;\n PointsReward?: number; // additional points into the SAME cycle points track; 0 = none\n}\n```\n\n`ClaimGroupCompletionReward` computes eligibility **live**, at claim time, by\nscanning the **current** `QuestDefinitions.Quests` for every quest that (a)\nlists this `cycleID` in its `Linking.CycleIDs` and (b) has `Linking.GroupID` equal to the\ncompletion's `GroupID` — that's `totalGroupQuests`. Of those, it counts how many\nhave reached `Status === \"Completed\"` **or** `\"Claimed\"` in the player's current\ncycle state — that's `completedGroupQuests`. The required threshold is\n`RequiredCompletedQuests` if `> 0`, otherwise `totalGroupQuests` (i.e. every\ngroup quest currently in config). Failure modes:\n\n- `RequiredCompletedQuests` unset and the group is empty/misconfigured (no\n quests currently reference that `GroupID` in that cycle) →\n `\"No quests configured for this group\"` (required resolves to `0`, which is\n rejected outright — you can never claim an empty group).\n- `completedGroupQuests < required` → `\"Not enough completed quests for this\ngroup\"`.\n- Already in `cycle.ClaimedGroupCompletionIDs` → `\"Group completion already\nclaimed\"` (checked in-memory before the DB round-trip, then re-enforced by an\n `AnyEq`-negated Mongo filter for the actual OCC guard).\n- `completion.GroupID` blank/whitespace on the definition itself →\n `\"Group completion has no GroupID\"` (a config error, not a player error).\n\nBecause the scan is **live against current config**, removing a quest from the\ngroup (or from the cycle) between when a player completed it and when they\nclaim the group reward can change `totalGroupQuests`/`completedGroupQuests` —\nthere's no snapshot of \"the group as it was.\" The response echoes\n`CompletedGroupQuests` and `RequiredGroupQuests` (the resolved threshold, not\nthe raw config field) so the client can show \"3 / 3\" without recomputing\nanything.\n\n---\n\n## `AddQuestProgress` server-side rules\n\nFull request-to-mutation path (`QuestV2.AddQuestProgress` public entry point +\nthe internal shared helper), summarized because several rules only make sense\ntogether:\n\n1. `MetricID` required; must match at least one `ClientApi`-sourced objective\n in the **entire** quest catalog, or the call fails with `\"MetricID not\nallowed for ClientApi\"` before touching the database.\n2. `ProgressValue` (`long`) must be `>= 0`.\n3. If any matching objective declares `MaxProgressPerCall > 0`, the **raw**\n value is checked against the smallest such cap across all matches; exceeding\n it **bans the account** (see the objective section above) rather than\n clamping — this is a hard security control, not UX guidance.\n4. The (possibly `Sum`-clamped) value then fans out to **every** quest/objective\n pair across **every currently-earning cycle and every permanent quest**\n whose objective's `Source === \"ClientApi\"` and `MetricID` matches — one\n `addQuestProgress` call can move several quests (even across different\n cycles) simultaneously if they all listen to the same metric.\n5. Each matched quest only advances if it isn't already `Completed`/`Claimed`\n (`ApplyToQuestInstance` early-returns `false` otherwise) — so calling\n `addQuestProgress` for an action a player keeps performing after a quest is\n done is safe and a no-op for that quest.\n6. The response's `Updates[]` (`QuestProgressUpdate`) lists **only the\n quest/objective pairs that actually changed** this call — an objective whose\n `Sum` increment was clamped to `0` (already at `TargetValue`) or a locked\n quest that accrued nothing produces no entry.\n7. This whole path calls `RefreshQuestCycles` first (unless the internal helper\n is invoked with `ensureCyclesUpToDate: false`, which the public\n `AddQuestProgress` action does to avoid double-refreshing) — so cycle\n windows are always current before progress is evaluated.\n\n---\n\n## Idempotency, atomicity, batch limits\n\n- **Idempotency keys** (`ResourceService.ResolveRelatedEntityID`, stable ID\n patterns from `Quest.cs`): single quest claim →\n `\"{questID}\"` (permanent) or `\"{questID}_{cycleStartUtc:yyyyMMddHHmmss}\"`\n (cyclic — so the **same** quest claimed again after the cycle rolls to a new\n window is a distinct idempotency key, not a duplicate); milestone claim →\n `\"{cycleID}_{milestoneID}_{instanceKey}\"`; group completion →\n `\"{cycleID}_{completionID}_{cycleStartUtc:yyyyMMddHHmmss}\"`. You never\n construct these yourself — the TS SDK mints its own client-side\n `RelatedEntityID` (`quest_claim_…`, `milestone_claim_…`, `group_completion_…`,\n each suffixed with a fresh UUID) purely for its own request-level tracking;\n the **server-side** idempotency guarantee comes from the stable IDs above\n plus the OCC filter on each claim, not from the client's `RelatedEntityID`.\n- **Atomicity.** Every claim path (`ClaimQuestReward`, `ClaimMilestoneReward`,\n `ClaimGroupCompletionReward`, and both batch variants) runs the reward grant\n and the state-mutating patches (status → `Claimed`, milestone `$push`, group\n `$addToSet`) inside **one** `ResourceService.ApplyResourceOperationAtomicAsync`\n call with an `extraFilter` re-asserting the pre-claim condition (e.g. quest\n `Status == Completed`) — if the grant fails for any reason (insufficient\n server-side room, a concurrent claim already flipped the filter condition,\n etc.) the whole transaction rolls back; there is no partially-applied claim.\n- **Resources in batch responses.** For both `ClaimQuestRewardsBatch` and\n `ClaimMilestoneRewardsBatch`, all included items' rewards are merged into a\n **single** `ResourceBundle` (`BatchSupport.MergeBundles`) and charged/granted\n in one call — the merged `ResourceOperation` is attached to only the **first\n successful** `BatchItemResult.Data.Resources` in the returned array; every\n other successful item's `Data.Resources` is an **empty** `ResourceOperation`\n (`new ResourceOperation()`), not a duplicate of the shared one. Don't sum\n resources across batch items — read them once from wherever they landed (the\n TS SDK's `applyResourceOperation` is only ever called once, on the first\n `Resources` it finds, matching this).\n- **Batch size.** `BatchSupport.MaxBatchSize = 50`. Both\n `claimQuestRewardsBatch` and `claimMilestoneRewardsBatch` accumulate refs from\n your array only up to 50 (after deduping by `CycleID+QuestID` /\n `CycleID+MilestoneID`); anything past the 50th valid, deduped entry is\n **silently dropped** — it never appears in the result array at all, so a\n `results.length` shorter than your input isn't necessarily an error. Chunk\n larger sets yourself.\n- **Batch validity filtering happens before charging.** Each item is\n independently checked (mongo-safety, config existence, gates, schedule\n window, prerequisites, current `Status`) and rejected into a preset\n `BatchItemResult` **before** the shared resource operation runs; only\n surviving items contribute to the merged grant and the combined Mongo filter\n (`AND` of each item's own OCC filter). That combined filter means: if even\n one surviving item's condition is no longer true by the time the transaction\n actually commits (e.g. a race with another request), **the entire merged\n operation fails** and every surviving item in that batch call reports the\n same `apply.Error` — \"partial-aware\" describes the **pre-filtering** stage,\n not protection against a mid-flight race on the shared charge.\n- **Rate limit / lock.** The whole `QuestV2` function uses\n `RateLimitMilliseconds = 500` (per-IP endpoint throttle) and\n `LockDurationMilliseconds = 10000` (per-user-action Mongo transaction lock)\n inside `ClientRun.Execute` — both are backend-side controls independent of\n the TS SDK's own 600ms client-side throttle guard.\n"
9
9
  }
10
10
  ]
11
11
  }