@idosgames/mcp 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -5
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +20 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +5 -5
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/authentication.json +3 -3
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/collection-system.json +1 -1
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/game-loop-system.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-title-bootstrap.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/lootbox-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +1 -1
- package/registry/skills/purchase-system.json +11 -0
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/reward-system.json +1 -1
- package/registry/skills/social-system.json +1 -1
- package/registry/skills/user-profile.json +2 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Reward data model — reference\n\nFull shape of the config (Definitions) and player state for all four\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\nand the milestone reward multiplier curve. The config side is **strictly\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\nlater still round-trips. The deeper nested shapes shown below as plain\n`interface` blocks in this doc (`DailyCalendarDefinition`,\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\ntypes — most of them are **not individually exported by name** from\n`@idosgames/core`'s public entry point today; don't write `import type {\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\nstyle indexed-access types if you need the standalone name). Per-user state\nobjects beyond the top-level four dictionaries are typed as lenient\npassthrough shapes on the SDK side — the fields documented below are what the\nbackend actually puts on them. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\n- [Tier-reward settings](#tier-reward-settings)\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\n\n---\n\n## Root config: RewardDefinitions\n\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\n`RewardDefinitions.cs`.\n\n```ts\ninterface RewardDefinitions {\n TierRewards?: TierRewardSettings | null;\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\n Claims?: Record<string, ClaimRewardDefinition> | null;\n}\n```\n\nEach of the four dictionaries is an **independent subsystem** — a title can\nuse only some of them; an empty/absent dictionary just means that subsystem is\noff. All four grant rewards through the same `ResourceGrant`, so premium\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\n\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\n\n```ts\ninterface UserRewardState {\n DailyCalendars?: Record<string, UserDailyCalendarState>;\n IdleAccruals?: Record<string, UserIdleAccrualState>;\n Comebacks?: Record<string, UserComebackState>;\n Claims?: Record<string, UserClaimRewardState>;\n}\n```\n\nAn absent entry in any of the four dictionaries means \"player never touched\nthis ID\" — the server treats it as default/zero state, not an error.\n\n---\n\n## Tier-reward settings\n\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\nrewards resolve across _every_ system that has tiers (premium, season, battle\npass, etc.), not just Reward itself. One mode per title.\n\n```ts\ninterface TierRewardSettings {\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\n RewardStackLowerTiers?: boolean; // default: false\n}\n```\n\n- `Additive` — tier rewards are added **on top of** the base reward.\n- `Replace` — tier rewards **fully replace** the base reward.\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\n `false` (default) — only the best matching tier applies.\n\nThis block is read by `ResourceService`, not by Reward's own claim logic\ndirectly — it's here because `RewardDefinitions` is where it's configured.\n\n---\n\n## Daily calendars\n\n### Config: `DailyCalendarDefinition`\n\n```ts\ninterface DailyCalendarDefinition {\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\n MissThresholdMultiplier?: number; // default 2.0\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\n Gate?: SegmentGate; // null/empty = everyone\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface DailyRewardDay {\n DayNumber?: number; // 1-based, unique per calendar\n Rewards?: ResourceGrant;\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserDailyCalendarState`\n\n```ts\ninterface UserDailyCalendarState {\n CalendarID?: string;\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\n}\n```\n\n### Claim eligibility (`ClaimMode`)\n\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\n\n- **`CalendarDayUtc`** (default): a new claim is available once\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\n `\"Daily reward already claimed today for this calendar\"` if the player\n already claimed on today's UTC date. Ignores player timezone.\n- **`SlidingWindow`**: a new claim is available once\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\n\n### Miss detection and `MissBehavior`\n\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\nfirst, before the new day is computed.\n\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\n- **Miss condition** (was the gap too large?):\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\n- **On miss**, `CollectedDays` becomes:\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\n rewards are forfeited, the streak count itself survives).\n - `ResetToStart` — `0` (hard streak reset).\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\n at 0).\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\n\n### Day resolution\n\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\nlooping — only the _day looked up_ wraps, not the counter).\n\nDefault-calendar resolution when `calendarID` is omitted: the server uses\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\nback to the first entry in the dictionary.\n\n---\n\n## Idle accruals\n\n### Config: `IdleAccrualDefinition`\n\n```ts\ninterface IdleAccrualDefinition {\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Rate?: IdleRateConfig;\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\n Requirements?: IdleAccrualRequirements;\n FirstClaimMode?:\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface IdleRateConfig {\n BaseRatePerSecond: number; // flat, unconditional\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\n}\n\ninterface PremiumTierMultiplier {\n MinPremiumTier?: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n Multiplier?: number;\n}\n\ninterface IdleAccrualRequirements {\n MinCharacterLevel: number; // 0 = not checked\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\n Gate?: SegmentGate; // null/empty = not checked\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\n}\n```\n\n### State: `UserIdleAccrualState`\n\n```ts\ninterface UserIdleAccrualState {\n AccrualID?: string;\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\n}\n```\n\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\n\n```\nrawRate = BaseRatePerSecond\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\n\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\n MinPremiumTier <= player's MaxActiveTier (and matching\n RequiredPremiumID if set); 1.0 if none match or premium is null\n — multipliers never stack.\n\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\n```\n\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\n\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\nsite yet. The config fields exist and round-trip, but don't promise \"gear\nboosts idle income\" in product copy until this is verified live via\n`AppliedRatePerSecond` in a real claim response.\n\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\n\n```\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\n = otherwise, resolved by FirstClaimMode:\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\n\nelapsedSeconds = max(0, now - effectiveStart) in seconds\naccruedSeconds = MaxAccumulationSeconds > 0\n ? min(elapsedSeconds, MaxAccumulationSeconds)\n : elapsedSeconds\n```\n\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\nTry again in {n}s\"`.\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\n zero-payout finalize — sets `LastCollectAt = now`, returns\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\n unscaled and are applied afterward by `ResourceService` as usual.\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\n\n### Requirements gate (checked every claim, not persisted)\n\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\n\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\nhigher premium tier\"`.\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\n'{id}' level {n} is below required {m}\"`.\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\n `\"Required item '{id}' is not in inventory\"`.\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\n'{charID}'\"`.\n\nFailing a requirement does **not** move `LastCollectAt` — once the\nrequirement is met again, the previously-accrued time (up to the cap) is still\ncollectible.\n\n---\n\n## Comeback rewards\n\n### Config: `ComebackRewardDefinition`\n\n```ts\ninterface ComebackRewardDefinition {\n ComebackID?: string; // key in Comebacks; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tiers?: ComebackTier[];\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\n TrackPresenceOnRead?: boolean; // default true\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ComebackTier {\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\n Rewards?: ResourceGrant;\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserComebackState`\n\n```ts\ninterface UserComebackState {\n ComebackID?: string;\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\n LastClaimAt: string; // ISO; MinValue = never claimed\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\n}\n```\n\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\n\nRuns on **every** claim call for this comeback, and also on\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\n\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\n stop — no absence to evaluate yet.\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\n both set): if `ClaimWindowSeconds > 0` and\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\n means it never expires on its own.)\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\n ties broken by taking the higher threshold). If a tier matches AND the\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\n\nThe tier is deliberately locked at the **moment of return**, not at claim\ntime — this stops a player from delaying the claim to try to \"grow into\" a\nricher tier.\n\n### Claim (`RewardV2.ClaimComebackReward`)\n\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\ntimestamp, so a stale pending anchor from a concurrent request can't be\ndouble-spent.\n\n---\n\n## Claim rewards\n\n### Config: `ClaimRewardDefinition`\n\n```ts\ninterface ClaimRewardDefinition {\n ClaimID?: string; // key in Claims; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\n Rewards?: ResourceGrant;\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ClaimLimitOverride {\n MinPremiumTier: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\n WindowSeconds?: number | null;\n TotalClaimLimit?: number | null;\n}\n```\n\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\n\n### State: `UserClaimRewardState`\n\n```ts\ninterface UserClaimRewardState {\n ClaimID?: string;\n TotalClaims: number; // monotonically increasing; never resets\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\n LastClaimAt: string; // ISO; MinValue = never claimed\n}\n```\n\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\n\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\nthe player has an active premium tier, the **one** override with the highest\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\noverrides never stack. Each of that override's four fields is applied only if\nnon-null; a null field falls back to the base `Limits` value, not to \"no\nlimit.\"\n\n### Claim validation order (`RewardV2.PrepareClaimReward`)\n\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\nclaimable by client (server-only)\"`), and `Rewards` is configured.\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\n4. Resolve effective limits (base + best override).\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\n `\"Total claim limit reached ({have}/{limit})\"`.\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\n `\"Reward is on cooldown. Try again in {n}s\"`.\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\n{window}s)\"`.\n8. On success, `now` is appended to the window list, then the list is\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\n first). `TotalClaims` increments by 1 regardless of window/cooldown\n settings.\n\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\nclient `claimReward` call against one is always rejected.\n\n### Batch claiming (backend-only today)\n\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\nindependently (invalid/ineligible ids are filtered out and reported before any\ncharge), then applies the merged valid set as a single atomic operation with\none combined `Resources` payload attached to the first successful result\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\nso this path is not reachable from the TS client yet.\n\n---\n\n## Milestone reward multiplier\n\n`RewardDefinitions.MilestoneRewardMultiplier` is a\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\nfour subsystems; it's a title-wide overlay that other milestone-bearing\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\n_last_ overlay in their reward-resolution chain.\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: ProgressionSource; // metric the multiplier is driven by\n SourceKey?: string; // disambiguator when Source needs one\n CurveType?: \"Tiered\" | \"Linear\"; // default Tiered\n Tiers?: { AtProgress?: number; Multiplier?: number }[]; // used when CurveType = Tiered\n TierMode?: \"Step\" | \"Linear\"; // interpolation BETWEEN tier breakpoints; default Step\n BaseMultiplier?: number; // floor value below the first tier / Linear curve's base\n PerUnit?: number; // used when CurveType = Linear\n Anchor?: number; // used when CurveType = Linear\n MinMultiplier?: number; // hard floor after evaluation\n MaxMultiplier?: number; // hard ceiling after evaluation; <= 0 = no ceiling\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\n`ProgressionSource` values (from `MilestoneModels.ts` /\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\n\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\n\n```\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\n\nif CurveType == \"Linear\":\n raw = BaseMultiplier + PerUnit * max(0, progress - Anchor)\n\nelse (CurveType == \"Tiered\", default):\n tiers sorted ascending by AtProgress\n if no tiers: raw = BaseMultiplier\n else if progress < tiers[0].AtProgress: raw = BaseMultiplier\n else if progress >= tiers[last].AtProgress: raw = tiers[last].Multiplier\n else: find bracketing tiers (lo, hi) where progress falls between AtProgress values\n if TierMode == \"Linear\": raw = lo.Multiplier + frac * (hi.Multiplier - lo.Multiplier)\n where frac = (progress - lo.AtProgress) / (hi.AtProgress - lo.AtProgress)\n else (\"Step\", default): raw = lo.Multiplier\n\nfinal = clamp(raw): NaN/Infinity -> 1.0; then floor at MinMultiplier;\n then ceiling at MaxMultiplier only if MaxMultiplier > 0\n```\n\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\nlive `Multiplier`, the raw `Progress` value read from the player's current\nprogression state, and echoes of `Source`/`SourceKey`.\n\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\n\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\nMatching for items is by `ItemID`; for currencies/event-tokens, by\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\nuntouched by this step — they're applied afterward, on top of the\nalready-scaled `Standard` bundle, by `ResourceService`.\n\n---\n\n## Shared plumbing\n\nThese blocks are reused by all four subsystems (and the rest of the\nplatform) — full details live in their own modules; summarized here only as\nthey affect Reward.\n\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\n used by `Gate` fields on `DailyCalendarDefinition`,\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\n a failing gate always surfaces as `reason: \"server\"` with a\n \"locked behind a higher premium tier\"-style message — there is no\n client-visible breakdown of _which_ gate condition failed.\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\n shared type but ignored by `RewardV2`.\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\n subsystem's `Rewards` field and every claim response's `data.Resources` use\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\n amount by the time it reaches the client.\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\n one of the four definition types follow the same rule:\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\n both may be absent for \"no bound.\"\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\n the three id-taking methods (not `claimDailyReward`'s optional\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\n trip for the common typo case.\n"
|
|
8
|
+
"content": "# Reward data model — reference\r\n\r\nFull shape of the config (Definitions) and player state for all four\r\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\r\nand the milestone reward multiplier curve. The config side is **strictly\r\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\r\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\r\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\r\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\r\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\r\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\r\nlater still round-trips. The deeper nested shapes shown below as plain\r\n`interface` blocks in this doc (`DailyCalendarDefinition`,\r\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\r\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\r\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\r\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\r\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\r\ntypes — most of them are **not individually exported by name** from\r\n`@idosgames/core`'s public entry point today; don't write `import type {\r\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\r\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\r\nstyle indexed-access types if you need the standalone name). Per-user state\r\nobjects beyond the top-level four dictionaries are typed as lenient\r\npassthrough shapes on the SDK side — the fields documented below are what the\r\nbackend actually puts on them. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\n## Contents\r\n\r\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\r\n- [Tier-reward settings](#tier-reward-settings)\r\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\r\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\r\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\r\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\r\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\r\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\r\n\r\n---\r\n\r\n## Root config: RewardDefinitions\r\n\r\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\r\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\r\n`RewardDefinitions.cs`.\r\n\r\n```ts\r\ninterface RewardDefinitions {\r\n TierRewards?: TierRewardSettings | null;\r\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\r\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\r\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\r\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\r\n Claims?: Record<string, ClaimRewardDefinition> | null;\r\n}\r\n```\r\n\r\nEach of the four dictionaries is an **independent subsystem** — a title can\r\nuse only some of them; an empty/absent dictionary just means that subsystem is\r\noff. All four grant rewards through the same `ResourceGrant`, so premium\r\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\r\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\r\n\r\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\r\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\r\n\r\n```ts\r\ninterface UserRewardState {\r\n DailyCalendars?: Record<string, UserDailyCalendarState>;\r\n IdleAccruals?: Record<string, UserIdleAccrualState>;\r\n Comebacks?: Record<string, UserComebackState>;\r\n Claims?: Record<string, UserClaimRewardState>;\r\n}\r\n```\r\n\r\nAn absent entry in any of the four dictionaries means \"player never touched\r\nthis ID\" — the server treats it as default/zero state, not an error.\r\n\r\n---\r\n\r\n## Tier-reward settings\r\n\r\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\r\nrewards resolve across _every_ system that has tiers (premium, season, battle\r\npass, etc.), not just Reward itself. One mode per title.\r\n\r\n```ts\r\ninterface TierRewardSettings {\r\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\r\n RewardStackLowerTiers?: boolean; // default: false\r\n}\r\n```\r\n\r\n- `Additive` — tier rewards are added **on top of** the base reward.\r\n- `Replace` — tier rewards **fully replace** the base reward.\r\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\r\n `false` (default) — only the best matching tier applies.\r\n\r\nThis block is read by `ResourceService`, not by Reward's own claim logic\r\ndirectly — it's here because `RewardDefinitions` is where it's configured.\r\n\r\n---\r\n\r\n## Daily calendars\r\n\r\n### Config: `DailyCalendarDefinition`\r\n\r\n```ts\r\ninterface DailyCalendarDefinition {\r\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\r\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\r\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\r\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\r\n MissThresholdMultiplier?: number; // default 2.0\r\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\r\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\r\n Gate?: SegmentGate; // null/empty = everyone\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface DailyRewardDay {\r\n DayNumber?: number; // 1-based, unique per calendar\r\n Rewards?: ResourceGrant;\r\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserDailyCalendarState`\r\n\r\n```ts\r\ninterface UserDailyCalendarState {\r\n CalendarID?: string;\r\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\r\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\r\n}\r\n```\r\n\r\n### Claim eligibility (`ClaimMode`)\r\n\r\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\r\n\r\n- **`CalendarDayUtc`** (default): a new claim is available once\r\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\r\n `\"Daily reward already claimed today for this calendar\"` if the player\r\n already claimed on today's UTC date. Ignores player timezone.\r\n- **`SlidingWindow`**: a new claim is available once\r\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\r\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\r\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\r\n\r\n### Miss detection and `MissBehavior`\r\n\r\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\r\nfirst, before the new day is computed.\r\n\r\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\r\n- **Miss condition** (was the gap too large?):\r\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\r\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\r\n- **On miss**, `CollectedDays` becomes:\r\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\r\n rewards are forfeited, the streak count itself survives).\r\n - `ResetToStart` — `0` (hard streak reset).\r\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\r\n at 0).\r\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\r\n\r\n### Day resolution\r\n\r\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\r\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\r\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\r\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\r\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\r\nlooping — only the _day looked up_ wraps, not the counter).\r\n\r\nDefault-calendar resolution when `calendarID` is omitted: the server uses\r\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\r\nback to the first entry in the dictionary.\r\n\r\n---\r\n\r\n## Idle accruals\r\n\r\n### Config: `IdleAccrualDefinition`\r\n\r\n```ts\r\ninterface IdleAccrualDefinition {\r\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Rate?: IdleRateConfig;\r\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\r\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\r\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\r\n Requirements?: IdleAccrualRequirements;\r\n FirstClaimMode?:\r\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface IdleRateConfig {\r\n BaseRatePerSecond: number; // flat, unconditional\r\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\r\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\r\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\r\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\r\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\r\n}\r\n\r\ninterface PremiumTierMultiplier {\r\n MinPremiumTier?: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n Multiplier?: number;\r\n}\r\n\r\ninterface IdleAccrualRequirements {\r\n MinCharacterLevel: number; // 0 = not checked\r\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\r\n Gate?: SegmentGate; // null/empty = not checked\r\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\r\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\r\n}\r\n```\r\n\r\n### State: `UserIdleAccrualState`\r\n\r\n```ts\r\ninterface UserIdleAccrualState {\r\n AccrualID?: string;\r\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\r\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\r\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\r\n}\r\n```\r\n\r\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\r\n\r\n```\r\nrawRate = BaseRatePerSecond\r\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\r\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\r\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\r\n\r\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\r\n MinPremiumTier <= player's MaxActiveTier (and matching\r\n RequiredPremiumID if set); 1.0 if none match or premium is null\r\n — multipliers never stack.\r\n\r\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\r\n```\r\n\r\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\r\n\r\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\r\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\r\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\r\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\r\nsite yet. The config fields exist and round-trip, but don't promise \"gear\r\nboosts idle income\" in product copy until this is verified live via\r\n`AppliedRatePerSecond` in a real claim response.\r\n\r\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\r\n\r\n```\r\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\r\n = otherwise, resolved by FirstClaimMode:\r\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\r\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\r\n\r\nelapsedSeconds = max(0, now - effectiveStart) in seconds\r\naccruedSeconds = MaxAccumulationSeconds > 0\r\n ? min(elapsedSeconds, MaxAccumulationSeconds)\r\n : elapsedSeconds\r\n```\r\n\r\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\r\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\r\nTry again in {n}s\"`.\r\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\r\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\r\n zero-payout finalize — sets `LastCollectAt = now`, returns\r\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\r\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\r\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\r\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\r\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\r\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\r\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\r\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\r\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\r\n unscaled and are applied afterward by `ResourceService` as usual.\r\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\r\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\r\n\r\n### Requirements gate (checked every claim, not persisted)\r\n\r\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\r\n\r\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\r\nhigher premium tier\"`.\r\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\r\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\r\n'{id}' level {n} is below required {m}\"`.\r\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\r\n `\"Required item '{id}' is not in inventory\"`.\r\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\r\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\r\n'{charID}'\"`.\r\n\r\nFailing a requirement does **not** move `LastCollectAt` — once the\r\nrequirement is met again, the previously-accrued time (up to the cap) is still\r\ncollectible.\r\n\r\n---\r\n\r\n## Comeback rewards\r\n\r\n### Config: `ComebackRewardDefinition`\r\n\r\n```ts\r\ninterface ComebackRewardDefinition {\r\n ComebackID?: string; // key in Comebacks; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Tiers?: ComebackTier[];\r\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\r\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\r\n TrackPresenceOnRead?: boolean; // default true\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ComebackTier {\r\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\r\n Rewards?: ResourceGrant;\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserComebackState`\r\n\r\n```ts\r\ninterface UserComebackState {\r\n ComebackID?: string;\r\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\r\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\r\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\r\n}\r\n```\r\n\r\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\r\n\r\nRuns on **every** claim call for this comeback, and also on\r\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\r\n\r\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\r\n stop — no absence to evaluate yet.\r\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\r\n both set): if `ClaimWindowSeconds > 0` and\r\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\r\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\r\n means it never expires on its own.)\r\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\r\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\r\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\r\n ties broken by taking the higher threshold). If a tier matches AND the\r\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\r\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\r\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\r\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\r\n\r\nThe tier is deliberately locked at the **moment of return**, not at claim\r\ntime — this stops a player from delaying the claim to try to \"grow into\" a\r\nricher tier.\r\n\r\n### Claim (`RewardV2.ClaimComebackReward`)\r\n\r\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\r\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\r\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\r\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\r\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\r\ntimestamp, so a stale pending anchor from a concurrent request can't be\r\ndouble-spent.\r\n\r\n---\r\n\r\n## Claim rewards\r\n\r\n### Config: `ClaimRewardDefinition`\r\n\r\n```ts\r\ninterface ClaimRewardDefinition {\r\n ClaimID?: string; // key in Claims; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\r\n Rewards?: ResourceGrant;\r\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\r\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ClaimLimitOverride {\r\n MinPremiumTier: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\r\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\r\n WindowSeconds?: number | null;\r\n TotalClaimLimit?: number | null;\r\n}\r\n```\r\n\r\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\r\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\r\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\r\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\r\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\r\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\r\n\r\n### State: `UserClaimRewardState`\r\n\r\n```ts\r\ninterface UserClaimRewardState {\r\n ClaimID?: string;\r\n TotalClaims: number; // monotonically increasing; never resets\r\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n}\r\n```\r\n\r\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\r\n\r\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\r\nthe player has an active premium tier, the **one** override with the highest\r\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\r\noverrides never stack. Each of that override's four fields is applied only if\r\nnon-null; a null field falls back to the base `Limits` value, not to \"no\r\nlimit.\"\r\n\r\n### Claim validation order (`RewardV2.PrepareClaimReward`)\r\n\r\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\r\nclaimable by client (server-only)\"`), and `Rewards` is configured.\r\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\r\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\r\n4. Resolve effective limits (base + best override).\r\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\r\n `\"Total claim limit reached ({have}/{limit})\"`.\r\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\r\n `\"Reward is on cooldown. Try again in {n}s\"`.\r\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\r\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\r\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\r\n{window}s)\"`.\r\n8. On success, `now` is appended to the window list, then the list is\r\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\r\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\r\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\r\n first). `TotalClaims` increments by 1 regardless of window/cooldown\r\n settings.\r\n\r\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\r\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\r\nclient `claimReward` call against one is always rejected.\r\n\r\n### Batch claiming (backend-only today)\r\n\r\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\r\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\r\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\r\nindependently (invalid/ineligible ids are filtered out and reported before any\r\ncharge), then applies the merged valid set as a single atomic operation with\r\none combined `Resources` payload attached to the first successful result\r\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\r\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\r\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\r\nso this path is not reachable from the TS client yet.\r\n\r\n---\r\n\r\n## Milestone reward multiplier\r\n\r\n`RewardDefinitions.MilestoneRewardMultiplier` is a\r\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\r\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\r\nfour subsystems; it's a title-wide overlay that other milestone-bearing\r\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\r\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\r\n_last_ overlay in their reward-resolution chain.\r\n\r\n```ts\r\ninterface RewardProgressionMultiplierSpec {\r\n Source?: ProgressionSource; // metric the multiplier is driven by\r\n SourceKey?: string; // disambiguator when Source needs one\r\n Curve?: ScalarCurveSpec; // the curve; base 1 unless Base is set. Empty = no scaling\r\n Anchor?: number; // progress value the curve starts counting from; empty = 0\r\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\r\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\r\n}\r\n```\r\n\r\n`ProgressionSource` values (from `MilestoneModels.ts` /\r\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\r\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\r\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\r\n\r\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\r\n\r\nThe eight fields that used to describe the curve here (`CurveType`, `Tiers`, `TierMode`,\r\n`BaseMultiplier`, `PerUnit`, `MinMultiplier`, `MaxMultiplier`) collapsed into one shared\r\n`ScalarCurveSpec`:\r\n\r\n| Old shape | Now |\r\n| --- | --- |\r\n| `CurveType: \"Tiered\"` + `Tiers` | `Shape: \"Table\"` with `Points: [{ AtStep, Value }]` |\r\n| `TierMode: \"Step\" \\| \"Linear\"` | `Interpolation: \"Step\" \\| \"Linear\"` (also `\"Geometric\"`) |\r\n| `CurveType: \"Linear\"` + `PerUnit` | `Shape: \"PerStepRate\"` (share of the base per unit) |\r\n| `BaseMultiplier` | `Base` (empty = 1, i.e. a multiplier that changes nothing) |\r\n| `MinMultiplier` / `MaxMultiplier` | `MinResult` / `MaxResult` — **empty means NO bound**, and `0` now means a real zero |\r\n\r\n```\r\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\r\n\r\nraw = evaluateCurve(spec.Curve, base = 1.0, step = progress, firstStep = spec.Anchor ?? 0)\r\nfinal = NaN/Infinity -> 1.0\r\n```\r\n\r\n⚠ **The floor \"a reward multiplier never REDUCES a reward\" is no longer a config field.**\r\nIt is a domain rule of the resolver: when the publisher sets no `MinResult`, the result is\r\nfloored at `1.0`. Deliberate reduction is expressed by a curve that DOES set `MinResult`\r\nbelow 1 — so it can only happen on purpose, never by a stray zero.\r\n\r\n⚠ **Before the first table point a curve is the IDENTITY, not the first point's value.**\r\nA player who has not reached the first tier gets no bonus at all.\r\n\r\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\r\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\r\nlive `Multiplier`, the raw `Progress` value read from the player's current\r\nprogression state, and echoes of `Source`/`SourceKey`.\r\n\r\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\r\n\r\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\r\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\r\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\r\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\r\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\r\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\r\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\r\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\r\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\r\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\r\nMatching for items is by `ItemID`; for currencies/event-tokens, by\r\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\r\nuntouched by this step — they're applied afterward, on top of the\r\nalready-scaled `Standard` bundle, by `ResourceService`.\r\n\r\n---\r\n\r\n## Shared plumbing\r\n\r\nThese blocks are reused by all four subsystems (and the rest of the\r\nplatform) — full details live in their own modules; summarized here only as\r\nthey affect Reward.\r\n\r\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\r\n used by `Gate` fields on `DailyCalendarDefinition`,\r\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\r\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\r\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\r\n a failing gate always surfaces as `reason: \"server\"` with a\r\n \"locked behind a higher premium tier\"-style message — there is no\r\n client-visible breakdown of _which_ gate condition failed.\r\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\r\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\r\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\r\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\r\n shared type but ignored by `RewardV2`.\r\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\r\n subsystem's `Rewards` field and every claim response's `data.Resources` use\r\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\r\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\r\n amount by the time it reaches the client.\r\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\r\n one of the four definition types follow the same rule:\r\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\r\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\r\n both may be absent for \"no bound.\"\r\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\r\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\r\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\r\n the three id-taking methods (not `claimDailyReward`'s optional\r\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\r\n trip for the common typo case.\r\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-system",
|
|
3
3
|
"description": "Build a friends / social system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.social (SocialService): load the friends list, incoming friend requests, and recommended friends, send/accept/decline friend requests, remove a friend, and read the social activity timeline (attacks, raids, friend-adds). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a friends list screen, friend-request inbox, add-friend flow, recommended friends / player search, or an activity feed, or otherwise touches client.social, SocialService, SocialModels, FriendPublicProfile, or the social timeline — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## The three lists + the feed\n\nPlayer social state (`UserSocialState`) has four independent arrays, all\nstring `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Only grown client-side by `sendFriendRequest`; there\n is no `getOutgoingRequests()` — track it from the cache after you send.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\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 social = client.social; // the SocialService\n```\n\nEvery social method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`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 target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\nThe response is **self-sufficient**: `Counters` carries the sizes of YOUR lists\nafter the operation (`FriendsCount`, `IncomingRequestsCount`,\n`OutgoingRequestsCount`), and `Target` carries the other side's public profile\nwhere the UI needs it right now — sending and accepting a request. Apply your\nown edit locally and reconcile against `Counters`; do not re-issue\n`getFriendsList()` just to redraw. `Target` is absent for decline/remove: the\nentry disappears from the list anyway, so the server does not read the profile.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\nsocial?.Accepted; // string[] of friend UserIDs\nsocial?.IncomingRequests; // string[] awaiting your accept/decline\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved\nsocial?.Timeline; // SocialTimelineEvent[]\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// There's no polling endpoint for the other side's decision — re-check via\n// getFriendsList()/getIncomingRequests() (e.g. on next screen focus) to see\n// if it was accepted (moves to Accepted) or the outgoing entry disappears.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\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- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
|
|
4
|
+
"content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## Counters (server) vs the three lists + the feed (local cache)\n\n⚠ **`UserSocialState` has two halves, and they come from different places.**\n\nThe **counters** — `FriendsCount`, `IncomingCount`, `OutgoingCount` — are what\nthe server actually sends inside player state, and they are correct the moment\nthe player logs in. Friendships and requests themselves live in their own edge\ncollection: they used to be three arrays inside the player document, which\nmeant whoever sent you a request grew *your* document, without a ceiling, and\nit was re-read on every one of *your* calls.\n\nThe **four arrays below are a local SDK cache**, not server state. Nothing\nfills them on login — each is filled by its own call, and `OutgoingRequests`\nonly ever by your own sends. They are lost on restart, because nothing\nre-sends them.\n\nPlan the UI around that: **badges and counts come from the counters, lists only\nfrom a screen that loads them.** A friends-count badge needs no call; a friends\nlist screen must call `getFriendsList()` or it renders empty for a player who\nhas friends.\n\nThe four arrays, all string `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Grown client-side by `sendFriendRequest`, and loaded\n from the server by `getOutgoingRequests()`. **Call it on any screen that\n offers \"Add friend\"**: without it the list only knows about sends made in\n *this* run, so after a restart (or on a second device) a player who already\n asked someone is offered \"Add\" again. An accepted request leaves this list\n and appears in `Accepted`.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\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 social = client.social; // the SocialService\n```\n\nEvery social method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`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 target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getOutgoingRequests()` | Load pending requests this player sent. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\nThe response is **self-sufficient**: `Counters` carries the sizes of YOUR lists\nafter the operation (`FriendsCount`, `IncomingRequestsCount`,\n`OutgoingRequestsCount`), and `Target` carries the other side's public profile\nwhere the UI needs it right now — sending and accepting a request. Apply your\nown edit locally and reconcile against `Counters`; do not re-issue\n`getFriendsList()` just to redraw. `Target` is absent for decline/remove: the\nentry disappears from the list anyway, so the server does not read the profile.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\n\n// Server-sent, correct immediately after login — use these for badges/counts.\nsocial?.FriendsCount; // number\nsocial?.IncomingCount; // number — e.g. the red dot on the friends tab\nsocial?.OutgoingCount; // number\n\n// Local cache — EMPTY until the matching call below has run at least once.\nsocial?.Accepted; // string[] of friend UserIDs — getFriendsList()\nsocial?.IncomingRequests; // string[] awaiting your accept/decline — getIncomingRequests()\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved — getOutgoingRequests()\nsocial?.Timeline; // SocialTimelineEvent[] — getTimeline()\n```\n\n⚠ Do not derive a count by taking `.length` of one of those arrays: before the\nmatching call has run they are empty, even for a player who has friends and\npending requests. That is exactly what the counters are for.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:outgoingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// That entry is local to this run — call getOutgoingRequests() when the screen\n// opens so a restarted app still shows \"Pending\" instead of \"Add\".\n// There's no push for the other side's decision — re-check via\n// getFriendsList()/getOutgoingRequests() (e.g. on next screen focus): an\n// accepted request moves to Accepted and leaves OutgoingRequests.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\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- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "user-profile",
|
|
3
3
|
"description": "Work with the player's own account/session state in the iDosGames TS SDK (@idosgames/core) via client.user (UserService): bootstrap the whole per-player cache at login (ClientState — title config + every module's user state), load the raw inventory snapshot (currencies, items, unstackable instances), read usage-time / session stats, change the username, and delete the account. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants login/session bootstrapping, a profile or account screen, usage-time / playtime tracking, username changes, account deletion, raw inventory reads, or otherwise touches client.user, UserService, ClientState, UserState, UserInventoryState, UsageTimeStats, or client.data.user.state — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: user-profile\ndescription: >-\n Work with the player's own account/session state in the iDosGames TS SDK\n (@idosgames/core) via client.user (UserService): bootstrap the whole\n per-player cache at login (ClientState — title config + every module's user\n state), load the raw inventory snapshot (currencies, items, unstackable\n instances), read usage-time / session stats, change the username, and delete\n the account. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants login/session\n bootstrapping, a profile or account screen, usage-time / playtime tracking,\n username changes, account deletion, raw inventory reads, or otherwise touches\n client.user, UserService, ClientState, UserState, UserInventoryState,\n UsageTimeStats, or client.data.user.state — even if they don't name the\n module explicitly.\n---\n\n# User profile & session (iDosGames TS SDK)\n\n`UserService` is the root/session module: it has no gameplay concept of its\nown (no \"profile\" entity to level up), and instead owns **the state bootstrap\nthat every other module builds on**. When a player logs in, `UserService` is\nwhat fetches the entire per-player state tree (`ClientState`) and the title's\npublic config in one call, mirrors both into the cache, and only then does the\nrest of the SDK have anything to read. Past login, it also covers a handful of\naccount-level actions that don't belong to any feature module: raw inventory\nreads, usage-time tracking, username changes, and account deletion.\n\nThis skill is for **using** the production `UserService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule —\nsurface the error, don't try to reproduce the check client-side.\n\n## Mental model: ClientState is the trunk, every module is a branch\n\n`client.data.user.state` (type `UserState`) is **one shared object**. Most\nfeature modules (`Character`, `Quest`, `Store`, `Lootbox`, `Reward`,\n`Leaderboard`, `Season`, `Premium`, `Match`, `Collection`, `CoopEvent`,\n`DealOffer`, `Referral`, `Social`, `CustomData`, `GameLoop`, `Blockchain`, …)\nown one key on it and write there through their own service. `UserService`\ndoesn't own most of those keys — it owns the **mechanism that first populates\nthe whole tree**, plus a few keys nobody else claims: `InventoryV2` (the only\none it also refreshes into the cache on its own, via `getUserInventory`),\n`EventToken` (read via `getEventTokens`, result-only), and the ambient\n`UserID` / `PublicData` / `Usage` / `EconomyTuning` fields that ride along on\nthe login `ClientState.User`.\n\n**Two module keys are declared on `UserState` but never populated by\n`getClientState`/`getClientStateExcept`: `TimedBoost` and `Marketplace`.** The\nbackend's `ClientState.User` builder (`UserV2` in `User.cs`) only copies\n`InventoryV2`, `EventToken`, `Premium`, `PublicData`, `Social`, `Quest`,\n`GameLoop`, `Season`, `CoopEvent`, `Collection`, `Lootbox`, `Store`,\n`DealOffer`, `Referral`, `Leaderboard`, `EconomyTuning`, `Usage`,\n`CustomData`, `Blockchain`, `Reward`, `Character`, and `Match` — `TimedBoost`\nand `Marketplace` are absent from both its default field list and its\nfield-copier table, even though the underlying DB document has both. Those\ntwo modules populate their own cache keys exclusively through their own\nfetch calls (`client.timedBoost.getActiveTimedBoosts()` →\n`applyTimedBoost`, `client.marketplace.getMyState()` →\n`applyMarketplaceState`) — never assume `state?.TimedBoost` or\n`state?.Marketplace` is populated just because you called a `ClientState`\nmethod. See each module's own skill for how to load them.\n\n`AuthenticationService` calls `UserService.getClientStateExcept(...)` internally\non every login method (`loginWithDeviceID`, etc.) — you don't normally call\n`getClientState`/`getClientStateExcept` yourself. It's exposed because:\n\n- a mid-session hard refresh (\"resync everything\") is a legitimate thing to\n trigger from a debug menu or a stale-cache recovery path;\n- `getClientStateExcept` lets you refetch everything **except** a field you\n want to preserve (the SDK itself uses this for `GameLoop`, which is loaded\n per-stage by the GameLoop feature and would otherwise get wiped by a\n mid-session state refresh).\n\n### `ClientState.Title` is often absent on the wire — and that is not an error\n\nThe title config is identical for every player and changes rarely, so the SDK\ncaches it across sessions. Each response carries `ClientState.TitleConfigVersion`;\nthe SDK stores it next to the config and sends it back as\n`KnownTitleConfigVersion` on the next call. When it still matches, the backend\n**omits the `Title` key entirely** and only the player state travels.\n\n`UserService` resolves this for you — it re-fills `result.data.Title` from local\nstorage before applying it, so `client.data.config.titlePublicConfiguration` is\nalways populated and nothing in game code changes. What you must **not** do is\nread `Title` straight off a raw envelope you captured yourself (a network log, a\nhand-rolled fetch) and conclude the config is gone.\n\nStorage is `localStorage` with a memory fallback; pass `configStorage` to\n`createIDosGamesClient` to supply your own (React Native, a native shell). Any\nstorage failure degrades to the previous behaviour — a full config download —\nnever to a broken launch. The cached config is public title data, not player\ndata, so it deliberately survives logout.\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// client.data.user.state and client.data.config are already populated here.\n\nconst user = client.user; // the UserService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`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), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |\n| `getClientState()` | Fetch the state tree using the backend's **default** field set (which omits `Usage`/`EconomyTuning`/`CustomData`) and replace the cache wholesale. | `ClientState` |\n| `getClientStateExcept(excludeFields?, excludeTitleFields?)` | Fetch **every** field except the named ones, and preserve the current cached value of the named `User.*` / `Title.*` keys instead of overwriting them with the response (used at login to protect `GameLoop`). Prefer this for resyncs. | `ClientState` |\n| `getUserInventory()` | Load this player's raw inventory (currencies, stackable/unstackable items). | `UserInventoryState` |\n| `getEventTokens()` | Load the player's event-token buckets (per-feature token balances, e.g. Quest points). | `UserEventTokensState` |\n| `getUsageTime()` | Load aggregated playtime stats (today/week/month/total, sessions, reactivations). | `UsageTimeStats` |\n| `addUsageTime(usageTime, isNewSession, sessionDurationSeconds)` | Report elapsed foreground time for this session (heartbeat call). | `SuccessResponse` |\n| `changeUsername(username)` | Change the player's username. | `ChangeUsernameResponse` (`Username`) |\n| `deleteUserAccount()` | Permanently delete the player's account. | `SuccessResponse` |\n\nOn success, each method emits an event (see below for exactly which), but only\n`getClientState`/`getClientStateExcept` and `getUserInventory` also write the\ncache — `getEventTokens`, `getUsageTime`, `addUsageTime`, `changeUsername`, and\n`deleteUserAccount` hand you the response and leave `client.data` untouched.\n`addUsageTime`'s request is sent with a\n`silent` transport flag, meaning it won't spam the global error/busy UI on\nfailure the way a user-initiated action would; treat it as a background\nheartbeat, not something you need a dedicated error toast for.\n\n## Reading state and reacting to changes\n\n```ts\n// Whole-tree reads (present after any getClientState* call, i.e. after login):\nconst state = client.data.user.state; // UserState | null\nstate?.UserID;\nstate?.PublicData; // denormalized public profile snapshot (Username, AvatarUrl, Level, Power, ...)\nstate?.Usage; // UserUsageState — server-persisted usage summary (see below)\nstate?.InventoryV2; // present after getClientState* or getUserInventory()\n\n// Title config, populated by the same call:\nimport type { TitlePublicConfigurationModel } from \"@idosgames/core\";\nclient.data.config.titlePublicConfiguration; // TitlePublicConfigurationModel | null\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n**UserService's own events** (emitted directly by the methods above):\n\n- `user:clientStateReceived` → `ClientState` — fires from both\n `getClientState()` and `getClientStateExcept()`.\n- `user:inventoryReceived` → `UserInventoryState`\n- `user:eventTokensReceived` → `UserEventTokensState`\n- `user:usageTimeReceived` → `UsageTimeStats`\n- `user:usageTimeAdded` → `SuccessResponse`\n- `user:accountDeleted` → `SuccessResponse`\n- `user:usernameChanged` → `ChangeUsernameResponse`\n\n**Cache-echo events (not UserService's)**: almost every other event under the\n`user:` prefix is the _shared cache namespace_ firing on writes made by\n**other** modules' services, not by `UserService` — e.g. `user:characterUpdated`\n(CharacterService), `user:questUpdated` (QuestService), `user:storeUpdated`\n(StoreService), `user:lootboxUpdated`, `user:rewardUpdated`,\n`user:timedEventUpdated`, `user:leaderboardUpdated`, `user:seasonUpdated`,\n`user:premiumUpdated`, `user:matchUpdated`, `user:collectionUpdated`,\n`user:coopEventUpdated`, `user:dealOfferUpdated`, `user:referralUpdated`,\n`user:socialUpdated`, `user:timedBoostUpdated`, `user:customDataUpdated`,\n`user:gameLoopUpdated`, `user:blockchainUpdated`, `user:marketplaceUpdated`,\n`user:virtualCurrencyUpdated`, `user:eventTokenUpdated`. Don't document or\ntreat those as UserService methods/events — they belong to their own module's\nskill (or, for the last two, are narrower sub-signals of `user:inventoryUpdated`\nfired by the shared resource-operation apply path).\n\nTwo exceptions genuinely belong to the shared cache itself rather than any one\nmodule:\n\n- `user:stateUpdated` — fires whenever `client.data.user.state` is replaced\n wholesale (i.e. after `applyUserState`, which both `getClientState()` and\n `getClientStateExcept()` trigger internally).\n- `user:anyUpdated` — the umbrella event; fires on **every** cache write from\n **every** module, including all of the above. Good for a single \"re-render\n everything\" hook; too coarse to react to a specific change.\n\n`user:inventoryUpdated` (distinct from `user:inventoryReceived`) also fires\nwhenever inventory changes as a side effect of another module's resource\ncharge/grant (equip, purchase, upgrade, etc.) — not just from\n`getUserInventory()`. Read balances from `client.data.user.state?.InventoryV2`\nrather than assuming only `UserService` writes there.\n\n```ts\nconst off = client.on(\"user:clientStateReceived\", (state) => {\n console.log(\"logged in as\", state.User?.UserID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Bootstrap after login (already done for you)\n\n```ts\nawait client.auth.loginWithDeviceID();\n// AuthenticationService already called getClientStateExcept([\"GameLoop\"], [\"GameLoop\"])\n// internally. client.data.user.state and client.data.config are populated now.\nconst state = client.data.user.state;\n```\n\nYou rarely need to call `getClientState()` / `getClientStateExcept()` yourself\n— only for an explicit \"resync\" action (e.g. a debug/settings-screen\n\"Force refresh\" button) or recovering from a suspected stale cache.\n\n### Force a full resync mid-session, without losing the active board\n\n```ts\nconst res = await client.user.getClientStateExcept([\"GameLoop\"], [\"GameLoop\"]);\nif (!res.ok) return showError(res.error);\n// Every module's User.* state and the title config are now fresh, except\n// GameLoop, which keeps whatever was cached before this call (the response's\n// GameLoop, if any, is discarded and the previous cached value re-applied).\n```\n\n### Show a profile / account screen\n\n```ts\nconst res = await client.user.getUsageTime();\nif (!res.ok) return showError(res.error);\nconst usage = res.data; // UsageTimeStats — Today/CurrentWeek/Total/TotalSessions, seconds\nconst publicData = client.data.user.state?.PublicData;\n\n// publicData.Username / AvatarUrl / Level / Power / BoardRank — denormalized\n// snapshot also used by other modules (leaderboards, social, PvP opponents).\n```\n\nRead the stats off the **result** — `getUsageTime()` does not write the cache.\n`client.data.user.state?.Usage` is a different type (`UserUsageState`, the\npersisted per-day aggregate) and is only as fresh as the last\n`getClientStateExcept` fetch (i.e. login/resync).\n\n### Report playtime (session heartbeat)\n\n```ts\nconst res = await client.user.addUsageTime(\n elapsedSeconds, // usageTime: seconds since the last heartbeat\n isFirstHeartbeatThisSession, // isNewSession\n sessionDurationSeconds, // total session length so far\n);\nif (!res.ok) return; // silent transport call — fail quietly, retry next tick\n```\n\nCall this periodically (e.g. every N seconds of foreground time) rather than\nonce at session end, so playtime survives an unexpected app kill.\n\n### Change username\n\n```ts\nconst res = await client.user.changeUsername(\"NewName123\");\nif (!res.ok) return showError(res.error); // \"INVALID_USERNAME\" — must be 3–24 chars after trimming\nconsole.log(res.data.Username); // the trimmed name the server stored\n```\n\nUsernames are a **display field**, not a login identity: the backend trims the\ninput, checks 3–24 characters, and stores it as-is — there is no uniqueness\ncheck, so two players can share a name. Note the SDK does **not** patch the\ncached `PublicData.Username` after this call — update your UI from\n`res.data.Username` (or re-fetch client state) rather than re-reading the cache.\n\n### Delete account\n\n```ts\nconst res = await client.user.deleteUserAccount();\nif (!res.ok) return showError(res.error);\nclient.auth.logout(); // clear local session/cache after a confirmed deletion\n```\n\nThere's no undo client-side or server-side — gate this behind an explicit\nconfirmation step in the UI; the SDK does not add its own \"are you sure\"\nprompt. The backend does a hard delete of this title's player document\n(matched by `UserID` + `TitleID`) — it removes this game's data for this\nplayer only, not other titles' data for the same platform account.\n\n### Read raw inventory (currencies + items)\n\n```ts\nawait client.user.getUserInventory();\nconst inv = client.data.user.state?.InventoryV2;\ninv?.VirtualCurrencies; // { currencyID: { Amount, Recharge?, Daily? } }\ninv?.CryptoCurrencies; // { currencyID: { Amount, Frozen, ... } } — decimal strings\ninv?.Items; // { itemID: { StackableAmount, UnstackableAmount, TotalAmount } }\ninv?.UnstackableItems; // { itemInstanceID: UnstackableItemInstanceState }\n```\n\nMost feature modules (Item, Character, Store, Lootbox) already keep\n`InventoryV2` current via their own resource-operation cache writes — you only\nneed to call `getUserInventory()` explicitly for an initial/standalone read or\na forced resync of inventory alone (cheaper than a full `getClientState()`).\n\n## Gotchas\n\n- **Don't call login-path methods redundantly.** `getClientStateExcept` runs\n automatically inside every `auth.*` login method. Calling `getClientState()`\n again right after login just re-fetches what you already have.\n- **`getClientStateExcept`'s exclusion is cache-side, not server-side.** The\n server still returns the excluded fields (or doesn't include them — either\n way the SDK ignores what it got back for them); the SDK's `applyClientState`\n re-applies the _previously cached_ value if the fresh response doesn't carry\n one. Use this to protect a key another feature is actively managing\n mid-session (the SDK itself only special-cases `GameLoop` today, but the\n mechanism is generic to any `UserState`/title-config key).\n- **`user:anyUpdated` is too coarse for targeted UI.** It fires on literally\n every cache write from every module. Prefer the specific event\n (`user:clientStateReceived`, `user:inventoryReceived`, a module's own\n `user:<domain>Updated`) unless you genuinely want a blanket re-render.\n- **`state?.TimedBoost` and `state?.Marketplace` are never filled by a\n `ClientState` fetch.** They exist on the `UserState` type, but the backend's\n `GetClientState`/`GetClientStateExcept` builder simply doesn't copy them —\n they're populated only after you call\n `client.timedBoost.getActiveTimedBoosts()` / `client.marketplace.getMyState()`\n at least once. If a profile/debug screen dumps `client.data.user.state` right\n after login, don't be surprised these two keys are missing even though\n everything else is populated.\n- **`addUsageTime` is a `silent` call.** It won't trigger the SDK's global\n error/busy signaling on failure the way a normal action does — build your\n own light retry/backoff for it if playtime accuracy matters, rather than\n relying on a global error handler to surface a problem.\n- **`PublicData` is a snapshot, not live state.** `UserState.PublicData` (and\n the same shape embedded in other modules' responses — leaderboard entries,\n social timeline actors, PvP/raid opponents, coop group members) is a\n denormalized copy taken at write time; it can lag behind the player's own\n live `InventoryV2`/`Character`/etc. Don't use it as a substitute for reading\n your own state.\n- **Crypto amounts are decimal strings.** `InventoryV2.CryptoCurrencies[id].Amount`\n and `.Frozen` are strings, not numbers — use a decimal library (the SDK uses\n `decimal.js` internally) for arithmetic, never native float math.\n",
|
|
4
|
+
"content": "---\nname: user-profile\ndescription: >-\n Work with the player's own account/session state in the iDosGames TS SDK\n (@idosgames/core) via client.user (UserService): bootstrap the whole\n per-player cache at login (ClientState — title config + every module's user\n state), load the raw inventory snapshot (currencies, items, unstackable\n instances), read usage-time / session stats, change the username, and delete\n the account. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants login/session\n bootstrapping, a profile or account screen, usage-time / playtime tracking,\n username changes, account deletion, raw inventory reads, or otherwise touches\n client.user, UserService, ClientState, UserState, UserInventoryState,\n UsageTimeStats, or client.data.user.state — even if they don't name the\n module explicitly.\n---\n\n# User profile & session (iDosGames TS SDK)\n\n`UserService` is the root/session module: it has no gameplay concept of its\nown (no \"profile\" entity to level up), and instead owns **the state bootstrap\nthat every other module builds on**. When a player logs in, `UserService` is\nwhat fetches the entire per-player state tree (`ClientState`) and the title's\npublic config in one call, mirrors both into the cache, and only then does the\nrest of the SDK have anything to read. Past login, it also covers a handful of\naccount-level actions that don't belong to any feature module: raw inventory\nreads, usage-time tracking, username changes, and account deletion.\n\nThis skill is for **using** the production `UserService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule —\nsurface the error, don't try to reproduce the check client-side.\n\n## Mental model: ClientState is the trunk, every module is a branch\n\n`client.data.user.state` (type `UserState`) is **one shared object**. Most\nfeature modules (`Character`, `Quest`, `Store`, `Lootbox`, `Reward`,\n`Leaderboard`, `Season`, `Premium`, `Match`, `Collection`, `CoopEvent`,\n`DealOffer`, `Referral`, `Social`, `CustomData`, `GameLoop`, `Blockchain`, …)\nown one key on it and write there through their own service. `UserService`\ndoesn't own most of those keys — it owns the **mechanism that first populates\nthe whole tree**, plus a few keys nobody else claims: `InventoryV2` (the only\none it also refreshes into the cache on its own, via `getUserInventory`),\n`EventToken` (read via `getEventTokens`, result-only), and the ambient\n`UserID` / `PublicData` / `Usage` / `EconomyTuning` fields that ride along on\nthe login `ClientState.User`.\n\n**Two module keys are declared on `UserState` but never populated by\n`getClientState`/`getClientStateExcept`: `TimedBoost` and `Marketplace`.** The\nbackend's `ClientState.User` builder (`UserV2` in `User.cs`) only copies\n`InventoryV2`, `EventToken`, `Premium`, `PublicData`, `Social`, `Quest`,\n`GameLoop`, `Season`, `CoopEvent`, `Collection`, `Lootbox`, `Store`,\n`DealOffer`, `Referral`, `Leaderboard`, `EconomyTuning`, `Usage`,\n`CustomData`, `Blockchain`, `Reward`, `Character`, and `Match` — `TimedBoost`\nand `Marketplace` are absent from both its default field list and its\nfield-copier table, even though the underlying DB document has both. Those\ntwo modules populate their own cache keys exclusively through their own\nfetch calls (`client.timedBoost.getActiveTimedBoosts()` →\n`applyTimedBoost`, `client.marketplace.getMyState()` →\n`applyMarketplaceState`) — never assume `state?.TimedBoost` or\n`state?.Marketplace` is populated just because you called a `ClientState`\nmethod. See each module's own skill for how to load them.\n\n`AuthenticationService` calls `UserService.getClientStateExcept(...)` internally\non every login method (`loginWithDeviceID`, etc.) — you don't normally call\n`getClientState`/`getClientStateExcept` yourself. It's exposed because:\n\n- a mid-session hard refresh (\"resync everything\") is a legitimate thing to\n trigger from a debug menu or a stale-cache recovery path;\n- `getClientStateExcept` lets you refetch everything **except** a field you\n want to preserve (the SDK itself uses this for `GameLoop`, which is loaded\n per-stage by the GameLoop feature and would otherwise get wiped by a\n mid-session state refresh).\n\n### `ClientState.Title` is often absent on the wire — and that is not an error\n\nThe title config is identical for every player and changes rarely, so the SDK\ncaches it across sessions. Each response carries `ClientState.TitleConfigVersion`;\nthe SDK stores it next to the config and sends it back as\n`KnownTitleConfigVersion` on the next call. When it still matches, the backend\n**omits the `Title` key entirely** and only the player state travels.\n\n`UserService` resolves this for you — it re-fills `result.data.Title` from local\nstorage before applying it, so `client.data.config.titlePublicConfiguration` is\nalways populated and nothing in game code changes. What you must **not** do is\nread `Title` straight off a raw envelope you captured yourself (a network log, a\nhand-rolled fetch) and conclude the config is gone.\n\nStorage is `localStorage` with a memory fallback; pass `configStorage` to\n`createIDosGamesClient` to supply your own (React Native, a native shell). Any\nstorage failure degrades to the previous behaviour — a full config download —\nnever to a broken launch. The cached config is public title data, not player\ndata, so it deliberately survives logout.\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// client.data.user.state and client.data.config are already populated here.\n\nconst user = client.user; // the UserService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`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), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |\n| `getClientState()` | Fetch the state tree using the backend's **default** field set (which omits `Usage`/`EconomyTuning`/`CustomData`) and replace the cache wholesale. | `ClientState` |\n| `getClientStateExcept(excludeFields?, excludeTitleFields?)` | Fetch **every** field except the named ones, and preserve the current cached value of the named `User.*` / `Title.*` keys instead of overwriting them with the response (used at login to protect `GameLoop`). Prefer this for resyncs. | `ClientState` |\n| `getUserInventory()` | Load this player's raw inventory (currencies, stackable/unstackable items). | `UserInventoryState` |\n| `getEventTokens()` | Load the player's event-token buckets (per-feature token balances, e.g. Quest points). | `UserEventTokensState` |\n| `getUsageTime()` | Load aggregated playtime stats (today/week/month/total, sessions, reactivations). | `UsageTimeStats` |\n| `addUsageTime(usageTime, isNewSession, sessionDurationSeconds)` | Report elapsed foreground time for this session (heartbeat call). | `SuccessResponse` |\n| `changeUsername(username)` | Change the player's username. | `ChangeUsernameResponse` (`Username`) |\n| `deleteUserAccount()` | Permanently delete the player's account. | `SuccessResponse` |\n\nOn success, each method emits an event (see below for exactly which), but only\n`getClientState`/`getClientStateExcept` and `getUserInventory` also write the\ncache — `getEventTokens`, `getUsageTime`, `addUsageTime`, `changeUsername`, and\n`deleteUserAccount` hand you the response and leave `client.data` untouched.\n`addUsageTime`'s request is sent with a\n`silent` transport flag, meaning it won't spam the global error/busy UI on\nfailure the way a user-initiated action would; treat it as a background\nheartbeat, not something you need a dedicated error toast for.\n\n## Reading state and reacting to changes\n\n```ts\n// Whole-tree reads (present after any getClientState* call, i.e. after login):\nconst state = client.data.user.state; // UserState | null\nstate?.UserID;\nstate?.PublicData; // denormalized public profile snapshot (Username, AvatarUrl, Level, Power, ...)\nstate?.Usage; // UserUsageState — server-persisted usage summary (see below)\nstate?.InventoryV2; // present after getClientState* or getUserInventory()\n\n// Title config, populated by the same call:\nimport type { TitlePublicConfigurationModel } from \"@idosgames/core\";\nclient.data.config.titlePublicConfiguration; // TitlePublicConfigurationModel | null\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n**UserService's own events** (emitted directly by the methods above):\n\n- `user:clientStateReceived` → `ClientState` — fires from both\n `getClientState()` and `getClientStateExcept()`.\n- `user:inventoryReceived` → `UserInventoryState`\n- `user:eventTokensReceived` → `UserEventTokensState`\n- `user:usageTimeReceived` → `UsageTimeStats`\n- `user:usageTimeAdded` → `SuccessResponse`\n- `user:accountDeleted` → `SuccessResponse`\n- `user:usernameChanged` → `ChangeUsernameResponse`\n\n**Cache-echo events (not UserService's)**: almost every other event under the\n`user:` prefix is the _shared cache namespace_ firing on writes made by\n**other** modules' services, not by `UserService` — e.g. `user:characterUpdated`\n(CharacterService), `user:questUpdated` (QuestService), `user:storeUpdated`\n(StoreService), `user:lootboxUpdated`, `user:rewardUpdated`,\n`user:timedEventUpdated`, `user:leaderboardUpdated`, `user:seasonUpdated`,\n`user:premiumUpdated`, `user:matchUpdated`, `user:collectionUpdated`,\n`user:coopEventUpdated`, `user:dealOfferUpdated`, `user:referralUpdated`,\n`user:socialUpdated`, `user:timedBoostUpdated`, `user:customDataUpdated`,\n`user:gameLoopUpdated`, `user:blockchainUpdated`, `user:marketplaceUpdated`,\n`user:virtualCurrencyUpdated`, `user:eventTokenUpdated`. Don't document or\ntreat those as UserService methods/events — they belong to their own module's\nskill (or, for the last two, are narrower sub-signals of `user:inventoryUpdated`\nfired by the shared resource-operation apply path).\n\nTwo exceptions genuinely belong to the shared cache itself rather than any one\nmodule:\n\n- `user:stateUpdated` — fires whenever `client.data.user.state` is replaced\n wholesale (i.e. after `applyUserState`, which both `getClientState()` and\n `getClientStateExcept()` trigger internally).\n- `user:anyUpdated` — the umbrella event; fires on **every** cache write from\n **every** module, including all of the above. Good for a single \"re-render\n everything\" hook; too coarse to react to a specific change.\n\n`user:inventoryUpdated` (distinct from `user:inventoryReceived`) also fires\nwhenever inventory changes as a side effect of another module's resource\ncharge/grant (equip, purchase, upgrade, etc.) — not just from\n`getUserInventory()`. Read balances from `client.data.user.state?.InventoryV2`\nrather than assuming only `UserService` writes there.\n\n```ts\nconst off = client.on(\"user:clientStateReceived\", (state) => {\n console.log(\"logged in as\", state.User?.UserID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Bootstrap after login (already done for you)\n\n```ts\nawait client.auth.loginWithDeviceID();\n// AuthenticationService already called getClientStateExcept([\"GameLoop\"], [\"GameLoop\"])\n// internally. client.data.user.state and client.data.config are populated now.\nconst state = client.data.user.state;\n```\n\nYou rarely need to call `getClientState()` / `getClientStateExcept()` yourself\n— only for an explicit \"resync\" action (e.g. a debug/settings-screen\n\"Force refresh\" button) or recovering from a suspected stale cache.\n\n### Force a full resync mid-session, without losing the active board\n\n```ts\nconst res = await client.user.getClientStateExcept([\"GameLoop\"], [\"GameLoop\"]);\nif (!res.ok) return showError(res.error);\n// Every module's User.* state and the title config are now fresh, except\n// GameLoop, which keeps whatever was cached before this call (the response's\n// GameLoop, if any, is discarded and the previous cached value re-applied).\n```\n\n### Show a profile / account screen\n\n```ts\nconst res = await client.user.getUsageTime();\nif (!res.ok) return showError(res.error);\nconst usage = res.data; // UsageTimeStats — Today/CurrentWeek/Total/TotalSessions, seconds\nconst publicData = client.data.user.state?.PublicData;\n\n// publicData.Username / AvatarUrl / Level / Power / BoardRank — denormalized\n// snapshot also used by other modules (leaderboards, social, PvP opponents).\n```\n\nRead the stats off the **result** — `getUsageTime()` does not write the cache.\n`client.data.user.state?.Usage` is a different type (`UserUsageState`, the\npersisted per-day aggregate) and is only as fresh as the last\n`getClientStateExcept` fetch (i.e. login/resync).\n\n⚠ **Per-day history is a WINDOW, not everything.** The server folds days older\nthan the window (400 by default) into `Usage.Monthly`, and months of finished\nyears into `Usage.Yearly`; the fold is irreversible. `UsageTimeStats.History`\nis that same windowed `Daily` map. So never sum per-day records for a lifetime\nor year-long figure — `Total`/`TotalSessions` are kept separately and are\nexact. See [references/data-model.md](references/data-model.md#userusagestate).\n\n### Report playtime (session heartbeat)\n\n```ts\nconst res = await client.user.addUsageTime(\n elapsedSeconds, // usageTime: seconds since the last heartbeat\n isFirstHeartbeatThisSession, // isNewSession\n sessionDurationSeconds, // total session length so far\n);\nif (!res.ok) return; // silent transport call — fail quietly, retry next tick\n```\n\nCall this periodically (e.g. every N seconds of foreground time) rather than\nonce at session end, so playtime survives an unexpected app kill.\n\n### Change username\n\n```ts\nconst res = await client.user.changeUsername(\"NewName123\");\nif (!res.ok) return showError(res.error); // \"INVALID_USERNAME\" — must be 3–24 chars after trimming\nconsole.log(res.data.Username); // the trimmed name the server stored\n```\n\nUsernames are a **display field**, not a login identity: the backend trims the\ninput, checks 3–24 characters, and stores it as-is — there is no uniqueness\ncheck, so two players can share a name. Note the SDK does **not** patch the\ncached `PublicData.Username` after this call — update your UI from\n`res.data.Username` (or re-fetch client state) rather than re-reading the cache.\n\n### Delete account\n\n```ts\nconst res = await client.user.deleteUserAccount();\nif (!res.ok) return showError(res.error);\nclient.auth.logout(); // clear local session/cache after a confirmed deletion\n```\n\nThere's no undo client-side or server-side — gate this behind an explicit\nconfirmation step in the UI; the SDK does not add its own \"are you sure\"\nprompt. The backend does a hard delete of this title's player document\n(matched by `UserID` + `TitleID`) — it removes this game's data for this\nplayer only, not other titles' data for the same platform account.\n\n### Read raw inventory (currencies + items)\n\n```ts\nawait client.user.getUserInventory();\nconst inv = client.data.user.state?.InventoryV2;\ninv?.VirtualCurrencies; // { currencyID: { Amount, Recharge?, Daily? } }\ninv?.CryptoCurrencies; // { currencyID: { Amount, Frozen, ... } } — decimal strings\ninv?.Items; // { itemID: { StackableAmount, UnstackableAmount, TotalAmount } }\ninv?.UnstackableItems; // { itemInstanceID: UnstackableItemInstanceState }\n```\n\nMost feature modules (Item, Character, Store, Lootbox) already keep\n`InventoryV2` current via their own resource-operation cache writes — you only\nneed to call `getUserInventory()` explicitly for an initial/standalone read or\na forced resync of inventory alone (cheaper than a full `getClientState()`).\n\n## Gotchas\n\n- **Don't call login-path methods redundantly.** `getClientStateExcept` runs\n automatically inside every `auth.*` login method. Calling `getClientState()`\n again right after login just re-fetches what you already have.\n- **`getClientStateExcept`'s exclusion is cache-side, not server-side.** The\n server still returns the excluded fields (or doesn't include them — either\n way the SDK ignores what it got back for them); the SDK's `applyClientState`\n re-applies the _previously cached_ value if the fresh response doesn't carry\n one. Use this to protect a key another feature is actively managing\n mid-session (the SDK itself only special-cases `GameLoop` today, but the\n mechanism is generic to any `UserState`/title-config key).\n- **`user:anyUpdated` is too coarse for targeted UI.** It fires on literally\n every cache write from every module. Prefer the specific event\n (`user:clientStateReceived`, `user:inventoryReceived`, a module's own\n `user:<domain>Updated`) unless you genuinely want a blanket re-render.\n- **`state?.TimedBoost` and `state?.Marketplace` are never filled by a\n `ClientState` fetch.** They exist on the `UserState` type, but the backend's\n `GetClientState`/`GetClientStateExcept` builder simply doesn't copy them —\n they're populated only after you call\n `client.timedBoost.getActiveTimedBoosts()` / `client.marketplace.getMyState()`\n at least once. If a profile/debug screen dumps `client.data.user.state` right\n after login, don't be surprised these two keys are missing even though\n everything else is populated.\n- **`addUsageTime` is a `silent` call.** It won't trigger the SDK's global\n error/busy signaling on failure the way a normal action does — build your\n own light retry/backoff for it if playtime accuracy matters, rather than\n relying on a global error handler to surface a problem.\n- **`PublicData` is a snapshot, not live state.** `UserState.PublicData` (and\n the same shape embedded in other modules' responses — leaderboard entries,\n social timeline actors, PvP/raid opponents, coop group members) is a\n denormalized copy taken at write time; it can lag behind the player's own\n live `InventoryV2`/`Character`/etc. Don't use it as a substitute for reading\n your own state.\n- **Crypto amounts are decimal strings.** `InventoryV2.CryptoCurrencies[id].Amount`\n and `.Frozen` are strings, not numbers — use a decimal library (the SDK uses\n `decimal.js` internally) for arithmetic, never native float math.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# User data model — reference\n\nFull shape of `ClientState`, `UserState`, and the other types owned by\n`packages/core/src/models/user/UserModels.ts`. Field names are PascalCase\n(straight from the backend JSON); schemas keep `.passthrough()` so a field the\nbackend adds later still round-trips. `UserState` is the single object every\nfeature module's cache hangs off of — this doc covers the trunk; each\nmodule's own skill/reference covers its own branch in depth.\n\n## Contents\n\n- [ClientState](#clientstate) — what `getClientState()` / `getClientStateExcept()` return\n- [UserState](#userstate) — the per-player state tree, `client.data.user.state`\n- [UserInventoryState](#userinventorystate) — what `getUserInventory()` returns\n- [UsageTimeStats](#usagetimestats) — what `getUsageTime()` returns\n- [UserUsageState](#userusagestate) — the persisted `UserState.Usage` shape\n- [Requests](#requests) — `UserRequest` fields\n- [Backend rules](#backend-rules-transcribed-from-usercs) — username validation, delete-account cascade\n\n---\n\n## ClientState\n\n```ts\ninterface ClientState {\n Title?: TitlePublicConfigurationModel | null; // -> client.data.config.titlePublicConfiguration\n User?: UserState | null; // -> client.data.user.state\n [k: string]: unknown;\n}\n```\n\nThe wire schema (`zClientState`) only validates that `Title`/`User` are present\n(or nullish) at the top level — it does not deep-validate their contents. This\nis intentional: `ClientState` carries the _entire_ title config and the\n_entire_ per-player state in one payload, and field-level schemas live with\neach owning module instead of being re-validated here.\n\n---\n\n## UserState\n\n```ts\ninterface UserState {\n UserID?: string;\n InventoryV2?: UserInventoryState;\n EventToken?: UserEventTokensState;\n PublicData?: UserPublicDataModel | null;\n EconomyTuning?: PlayerEconomyTuningState | null;\n Usage?: UserUsageState | null;\n\n // One key per feature module — each owned and written by that module's own\n // service, not by UserService. Present here only because they all live on\n // the same shared state object.\n Store?: UserStoreState | null;\n Lootbox?: UserLootboxState | null;\n Reward?: UserRewardState | null;\n Quest?: UserQuestState | null;\n Leaderboard?: UserLeaderboardsState | null;\n Season?: UserSeasonsState | null;\n Premium?: UserPremiumState | null;\n Character?: UserCharactersState | null;\n Match?: UserMatchState | null;\n Collection?: UserCollectionState | null;\n CoopEvent?: UserCoopEventState | null;\n DealOffer?: UserDealOffersState | null;\n Referral?: UserReferralState | null;\n Social?: UserSocialState | null;\n TimedBoost?: UserTimedBoostsState | null; // NOT populated by ClientState — see note below\n CustomData?: UserCustomDataState | null;\n GameLoop?: UserGameLoopsState | null;\n Blockchain?: UserBlockchainState | null;\n Marketplace?: UserMarketplaceState | null; // NOT populated by ClientState — see note below\n\n [k: string]: unknown; // future/unmodeled fields round-trip via passthrough\n}\n```\n\nFields **actually owned by UserService** (written by its own cache calls, not\nby another module): `InventoryV2` (via `getUserInventory()`, also kept current\nby other modules' resource operations), `EventToken` (via `getEventTokens()`),\nand the whole tree wholesale via `getClientState()`/`getClientStateExcept()`.\n`PublicData`, `EconomyTuning`, and `Usage` arrive as part of that wholesale\nfetch — there's no dedicated \"get just PublicData\" call.\n\n### `TimedBoost` and `Marketplace` are envelope-only — ClientState never fills them\n\nThe TS type declares `TimedBoost` and `Marketplace` because they're real keys\non the backend's `UserDataDocument` (`IDosGamesSDK/API/Client/v2/User/Models/UserDataDocument.cs:69,89`),\nbut the backend's `ClientState.User` builder does not copy either one. The\nresponse's `UserState` class\n(`IDosGamesSDK/API/Client/v2/User/Models/ClientState.cs:22-48`) has no\n`TimedBoost`/`Marketplace` property at all, and in\n`IDosGamesSDK/API/Client/v2/User/User.cs` neither name appears in\n`_defaultUserFields` (lines 279-304) nor in `_userStateFieldCopiers` (lines\n310-334) — every other module listed above (including `Match`, `Blockchain`,\n`Reward`, `Character`, which read as later additions) does appear in both.\nPractically: `getClientState()` / `getClientStateExcept()` will never\npopulate `state.TimedBoost` or `state.Marketplace`, regardless of\n`Fields`/`ExcludeFields` (those two names aren't in the copier table to\nselect in the first place).\n\nEach module owns and fetches its own per-player state instead, and the TS\ncache (`packages/core/src/cache/UserData.ts`) only ever writes these two keys\nfrom that module's own apply method:\n\n- `TimedBoost` ← `client.timedBoost.getActiveTimedBoosts()` →\n `UserData.applyTimedBoost({ Active })` — see the timed-boost-system skill.\n- `Marketplace` ← `client.marketplace.getMyState()` →\n `UserData.applyMarketplaceState(result.data.Limits)` — see the\n marketplace-system skill.\n\n### PublicData — denormalized public profile\n\n```ts\ninterface UserPublicDataModel {\n Username?: string | null;\n Country?: string | null;\n AvatarUrl?: string | null;\n Premium?: boolean | null;\n Level?: number | null;\n Power?: number | null;\n BoardRank?: number | null;\n [k: string]: unknown;\n}\n```\n\nThis same shape is embedded (as a snapshot, not a live reference) in several\nother modules' responses: leaderboard entries (`PublicProfile`), collection\ntrade offers (`SenderPublicData`), social timeline events (`ActorProfile`),\nGameLoop PvP/raid opponents (`TargetPublicData`/`PublicData`), and coop-event\ngroup members (`PublicData`). Treat any of those as a point-in-time copy, not\nas this player's live state.\n\n### EconomyTuning — personal balance multiplier\n\n```ts\ninterface PlayerEconomyTuningState {\n Segment: string;\n RewardMultiplier: number;\n CostMultiplier: number;\n MaxRollMultiplierOverride: number;\n ExpiresAtUtc: string;\n Version: number;\n}\n```\n\nServer-computed A/B or player-segment tuning (e.g. a new-player reward boost).\nIntended for server-side use in pricing/reward math; present on `ClientState.User`\nmainly so the client can display it (e.g. \"2x rewards active\") if the title\nchooses to.\n\n---\n\n## UserInventoryState\n\nReturned by `getUserInventory()`; cached at `client.data.user.state.InventoryV2`.\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable item totals, key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface UserVirtualCurrencyState {\n Amount: number;\n Recharge?: UserRechargeState | null; // energy-style regen, if configured\n Daily?: UserDailyCounters | null; // daily earn/spend caps tracking\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n\ninterface UserRechargeState {\n LastRechargeAt?: string;\n PendingSeconds?: number;\n}\n\ninterface UserDailyCounters {\n PeriodStartUtc: string;\n Earned: number;\n Spent: number;\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — use a decimal library, not native float\n Frozen: string; // decimal string\n DepositAddresses?: Record<string, UserDepositAddress>;\n Compliance?: UserCryptoComplianceCounters;\n CreatedAt?: string;\n UpdatedAt?: string;\n [k: string]: unknown;\n}\n\ninterface UserDepositAddress {\n Address: string;\n Memo?: string | null;\n AssignedAt: string;\n}\n\n/** AML spend-compliance window counters, checked against CryptoCurrencyDefinition.Limits. */\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc: string;\n DailyWithdrawnUsd: string; // decimal string\n MonthlyPeriodStartUtc: string;\n MonthlyWithdrawnUsd: string; // decimal string\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number;\n RemainingUses?: number;\n Level?: number; // per-instance upgrade level (Item module's UpgradeLevel)\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for equip state\n CustomData?: string | null;\n}\n\n/** Daily-window counter for one conversion pair (\"{srcType}:{srcID}->{tgtType}:{tgtID}\"). */\ninterface ConversionDailyCounter {\n PeriodStartUtc: string;\n AmountToday: string; // decimal string\n}\n```\n\n`EquippedSlot` on an unstackable item instance is the authoritative record of\nwhat's equipped where — the Character module's per-character `Equipment` map\nis a convenience cache view kept in sync with this by the SDK.\n\nThe wire schema for `UserInventoryState` (`zUserInventoryState`) is currently\nlenient (`z.object({}).passthrough()` cast to the type) — Phase 1 of the\nstrict-typing port validated `ClientState`'s top-level shape only; inventory's\nfield-level schema is expected to tighten in a later phase. Don't rely on\nruntime validation catching a malformed inventory field today; the TS types\nare still accurate for what the backend currently sends.\n\n---\n\n## UsageTimeStats\n\nReturned by `getUsageTime()` (a fetch/response type — distinct from the\npersisted `UserUsageState` below).\n\n```ts\ninterface UsageTimeStats {\n Today: number;\n Yesterday: number;\n CurrentWeek: number;\n CurrentMonth: number;\n Total: number;\n TotalSessions: number;\n FirstActiveAt?: string | null;\n LastActiveAt?: string | null;\n LongestSessionEverSeconds?: number | null;\n CurrentWeekActiveHoursMask?: number | null;\n CurrentMonthActiveHoursMask?: number | null;\n ReactivationCount?: number | null;\n History?: Record<string, unknown> | null;\n [k: string]: unknown;\n}\n```\n\nAll duration fields are seconds. `*ActiveHoursMask` is a bitmask (which hours\nof the day had activity) — decode per-bit if you need an activity heatmap.\n\n## UserUsageState\n\nThe persisted shape on `UserState.Usage` (`UserDataDocument.Usage` server-side)\n— foreground-activity seconds only, aggregated per day.\n\n```ts\ninterface UserUsageState {\n TotalSeconds: number;\n TotalSessions: number;\n FirstActiveAt?: string | null;\n LastActiveAt: string;\n Reactivations?: UsageReactivationEvent[];\n Daily?: Record<string, DailyUsageRecord>; // key = \"ddMMyyyy\" (UTC)\n}\n\n/** One recorded reactivation — a return after a silence gap of 7+ days. */\ninterface UsageReactivationEvent {\n ReactivatedAt: string;\n DaysSinceLastActive: number;\n}\n\ninterface DailyUsageRecord {\n Seconds: number;\n Sessions: number;\n LongestSessionSeconds: number;\n ActiveHoursMask: number;\n LastActiveAt: string;\n}\n```\n\n`UsageTimeStats` (from `getUsageTime()`) and `UserUsageState` (on `UserState.Usage`)\noverlap in intent but are separate types with separate field names — don't\nconflate `Today`/`CurrentWeek`/`CurrentMonth` (rolling windows in the fetch\nresponse) with `Daily` (a raw per-day map, keyed by date, in the persisted\nstate).\n\n---\n\n## Requests\n\n```ts\ninterface UserRequest extends BaseRequest {\n IsNewSession?: boolean; // addUsageTime\n SessionDurationSeconds?: number; // addUsageTime\n Fields?: string[]; // reserved — not currently populated by UserService\n TitleFields?: string[]; // reserved — not currently populated by UserService\n ExcludeFields?: string[]; // getClientStateExcept — User.* keys to preserve from cache\n ExcludeTitleFields?: string[]; // getClientStateExcept — Title.* keys to preserve from cache\n}\n```\n\n`BaseRequest` supplies `UserID`, `ClientSessionTicket`, `BuildKey`,\n`WebAppLink`, and a fresh `RelatedEntityID` per call (all filled in\nautomatically by `UserService`'s internal `baseRequest()` — you never build a\n`UserRequest` by hand). `Fields`/`TitleFields` exist on the request type but\n`UserService`'s methods never set them today — only `ExcludeFields` /\n`ExcludeTitleFields` are wired up, exclusively by `getClientStateExcept`.\n`BaseRequest` also carries `Username` and `UsageTime` (used by\n`changeUsername`/`addUsageTime` respectively) — those two live on the shared\n`BaseRequest` interface, not on `UserRequest` itself, since other modules'\nrequests (e.g. registration) also set `Username`.\n\n---\n\n## Backend rules (transcribed from `User.cs`)\n\n### Username validation (`ChangeUsername`)\n\nSource: `IDosGamesSDK/API/Client/v2/User/User.cs:245-267`.\n\n1. The server trims the incoming `Username` (`args.Username?.Trim()`).\n2. Rejects with the literal string `\"INVALID_USERNAME\"` if the trimmed value\n is null/whitespace, or its length is `< 3` or `> 24` characters. There is\n no character-set restriction beyond length — any non-whitespace string in\n range is accepted verbatim (no profanity filter, no uniqueness check).\n3. On success it does a single field patch\n (`Builders<UserDataDocument>.Update.Set(u => u.PublicData.Username, desired)`)\n directly via `ResourceService.PatchUserDataDocumentByIDAsync` — it does\n **not** go through `ResourceService`'s resource-operation/OCC path, so\n there's no idempotency-by-reason key for this call; a resend just\n overwrites the name again with the same (or a new) value.\n4. The response (`ChangeUsernameResponse.Username`) is the exact trimmed\n string that was stored — never a suffixed/deduplicated variant, since\n there's no uniqueness constraint to disambiguate against.\n\n### Delete-account cascade (`DeleteUserAccount`)\n\nSource: `IGSService.DeleteUserAccount` (`IDosGamesSDK/Core/CoreScripts/IGServer/Service/IGSService.cs:1275-1278`)\n→ `DataBaseService.DeleteAllDataUserAsync` (`IDosGamesSDK/Core/CoreScripts/IGServer/DataBase/DataBaseService.cs:498-519`).\n\n- The call is a single MongoDB `DeleteOneAsync` against the `USER_TYPE`\n collection, filtered by `UserID` **and** `TitleID` together. It deletes\n exactly one `UserDataDocument` — this title's per-player document only.\n- There is no fan-out to other collections in this code path: no explicit\n cleanup of Marketplace escrow/listings, leaderboard documents, coop-group\n membership, blockchain deposit-address records, or the cross-title\n `PlatformUserDocument`/`LinkedTitleAccounts` map. If a player is linked to a\n platform account, deleting one title's `UserDataDocument` does not unlink\n or delete the platform-level document or any other title's data.\n- The operation returns a plain `bool` (`DeletedCount > 0`); the endpoint\n turns a `false` into `OperationResult.Fail(\"Failed to Delete User Account\")`\n (`User.cs:269-275`). There is no soft-delete/undo flag anywhere in this\n path — a successful call is a permanent, synchronous hard delete of the row.\n"
|
|
8
|
+
"content": "# User data model — reference\n\nFull shape of `ClientState`, `UserState`, and the other types owned by\n`packages/core/src/models/user/UserModels.ts`. Field names are PascalCase\n(straight from the backend JSON); schemas keep `.passthrough()` so a field the\nbackend adds later still round-trips. `UserState` is the single object every\nfeature module's cache hangs off of — this doc covers the trunk; each\nmodule's own skill/reference covers its own branch in depth.\n\n## Contents\n\n- [ClientState](#clientstate) — what `getClientState()` / `getClientStateExcept()` return\n- [UserState](#userstate) — the per-player state tree, `client.data.user.state`\n- [UserInventoryState](#userinventorystate) — what `getUserInventory()` returns\n- [UsageTimeStats](#usagetimestats) — what `getUsageTime()` returns\n- [UserUsageState](#userusagestate) — the persisted `UserState.Usage` shape\n- [Requests](#requests) — `UserRequest` fields\n- [Backend rules](#backend-rules-transcribed-from-usercs) — username validation, delete-account cascade\n\n---\n\n## ClientState\n\n```ts\ninterface ClientState {\n Title?: TitlePublicConfigurationModel | null; // -> client.data.config.titlePublicConfiguration\n User?: UserState | null; // -> client.data.user.state\n [k: string]: unknown;\n}\n```\n\nThe wire schema (`zClientState`) only validates that `Title`/`User` are present\n(or nullish) at the top level — it does not deep-validate their contents. This\nis intentional: `ClientState` carries the _entire_ title config and the\n_entire_ per-player state in one payload, and field-level schemas live with\neach owning module instead of being re-validated here.\n\n---\n\n## UserState\n\n```ts\ninterface UserState {\n UserID?: string;\n InventoryV2?: UserInventoryState;\n EventToken?: UserEventTokensState;\n PublicData?: UserPublicDataModel | null;\n EconomyTuning?: PlayerEconomyTuningState | null;\n Usage?: UserUsageState | null;\n\n // One key per feature module — each owned and written by that module's own\n // service, not by UserService. Present here only because they all live on\n // the same shared state object.\n Store?: UserStoreState | null;\n Lootbox?: UserLootboxState | null;\n Reward?: UserRewardState | null;\n Quest?: UserQuestState | null;\n Leaderboard?: UserLeaderboardsState | null;\n Season?: UserSeasonsState | null;\n Premium?: UserPremiumState | null;\n Character?: UserCharactersState | null;\n Match?: UserMatchState | null;\n Collection?: UserCollectionState | null;\n CoopEvent?: UserCoopEventState | null;\n DealOffer?: UserDealOffersState | null;\n Referral?: UserReferralState | null;\n Social?: UserSocialState | null;\n TimedBoost?: UserTimedBoostsState | null; // NOT populated by ClientState — see note below\n CustomData?: UserCustomDataState | null;\n GameLoop?: UserGameLoopsState | null;\n Blockchain?: UserBlockchainState | null;\n Marketplace?: UserMarketplaceState | null; // NOT populated by ClientState — see note below\n\n [k: string]: unknown; // future/unmodeled fields round-trip via passthrough\n}\n```\n\nFields **actually owned by UserService** (written by its own cache calls, not\nby another module): `InventoryV2` (via `getUserInventory()`, also kept current\nby other modules' resource operations), `EventToken` (via `getEventTokens()`),\nand the whole tree wholesale via `getClientState()`/`getClientStateExcept()`.\n`PublicData`, `EconomyTuning`, and `Usage` arrive as part of that wholesale\nfetch — there's no dedicated \"get just PublicData\" call.\n\n### `TimedBoost` and `Marketplace` are envelope-only — ClientState never fills them\n\nThe TS type declares `TimedBoost` and `Marketplace` because they're real keys\non the backend's `UserDataDocument` (`IDosGamesSDK/API/Client/v2/User/Models/UserDataDocument.cs:69,89`),\nbut the backend's `ClientState.User` builder does not copy either one. The\nresponse's `UserState` class\n(`IDosGamesSDK/API/Client/v2/User/Models/ClientState.cs:22-48`) has no\n`TimedBoost`/`Marketplace` property at all, and in\n`IDosGamesSDK/API/Client/v2/User/User.cs` neither name appears in\n`_defaultUserFields` (lines 279-304) nor in `_userStateFieldCopiers` (lines\n310-334) — every other module listed above (including `Match`, `Blockchain`,\n`Reward`, `Character`, which read as later additions) does appear in both.\nPractically: `getClientState()` / `getClientStateExcept()` will never\npopulate `state.TimedBoost` or `state.Marketplace`, regardless of\n`Fields`/`ExcludeFields` (those two names aren't in the copier table to\nselect in the first place).\n\nEach module owns and fetches its own per-player state instead, and the TS\ncache (`packages/core/src/cache/UserData.ts`) only ever writes these two keys\nfrom that module's own apply method:\n\n- `TimedBoost` ← `client.timedBoost.getActiveTimedBoosts()` →\n `UserData.applyTimedBoost({ Active })` — see the timed-boost-system skill.\n- `Marketplace` ← `client.marketplace.getMyState()` →\n `UserData.applyMarketplaceState(result.data.Limits)` — see the\n marketplace-system skill.\n\n### PublicData — denormalized public profile\n\n```ts\ninterface UserPublicDataModel {\n Username?: string | null;\n Country?: string | null;\n AvatarUrl?: string | null;\n Premium?: boolean | null;\n Level?: number | null;\n Power?: number | null;\n BoardRank?: number | null;\n [k: string]: unknown;\n}\n```\n\nThis same shape is embedded (as a snapshot, not a live reference) in several\nother modules' responses: leaderboard entries (`PublicProfile`), collection\ntrade offers (`SenderPublicData`), social timeline events (`ActorProfile`),\nGameLoop PvP/raid opponents (`TargetPublicData`/`PublicData`), and coop-event\ngroup members (`PublicData`). Treat any of those as a point-in-time copy, not\nas this player's live state.\n\n### EconomyTuning — personal balance multiplier\n\n```ts\ninterface PlayerEconomyTuningState {\n Segment: string;\n RewardMultiplier: number;\n CostMultiplier: number;\n MaxRollMultiplierOverride: number;\n ExpiresAtUtc: string;\n Version: number;\n}\n```\n\nServer-computed A/B or player-segment tuning (e.g. a new-player reward boost).\nIntended for server-side use in pricing/reward math; present on `ClientState.User`\nmainly so the client can display it (e.g. \"2x rewards active\") if the title\nchooses to.\n\n---\n\n## UserInventoryState\n\nReturned by `getUserInventory()`; cached at `client.data.user.state.InventoryV2`.\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable item totals, key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface UserVirtualCurrencyState {\n Amount: number;\n Recharge?: UserRechargeState | null; // energy-style regen, if configured\n Daily?: UserDailyCounters | null; // daily earn/spend caps tracking\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n\ninterface UserRechargeState {\n LastRechargeAt?: string; // reference point of the next accrual (ISO, UTC)\n PendingSeconds?: number; // carried time that did not add up to a FULL period (< Period)\n}\n\n// Recharge is credited in BATCHES: every full `Recharge.Period` the player gets\n// `Recharge.Rate` units at once (see the currency-system skill). The server materializes\n// it lazily — on a state read and on any operation with that currency — so the `Amount`\n// you receive is already up to date; there is no background job and no push.\n//\n// Countdown to the NEXT BATCH (not to the next single unit):\n// const elapsed = (Date.now() - Date.parse(LastRechargeAt)) / 1000 + PendingSeconds;\n// const secondsToNextBatch = Math.max(0, Period - elapsed);\n// At or above `Recharge.Max` there is no countdown: nothing accrues until the player spends.\n\ninterface UserDailyCounters {\n PeriodStartUtc: string;\n Earned: number;\n Spent: number;\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — use a decimal library, not native float\n Frozen: string; // decimal string\n DepositAddresses?: Record<string, UserDepositAddress>;\n Compliance?: UserCryptoComplianceCounters;\n CreatedAt?: string;\n UpdatedAt?: string;\n [k: string]: unknown;\n}\n\ninterface UserDepositAddress {\n Address: string;\n Memo?: string | null;\n AssignedAt: string;\n}\n\n/** AML spend-compliance window counters, checked against CryptoCurrencyDefinition.Limits. */\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc: string;\n DailyWithdrawnUsd: string; // decimal string\n MonthlyPeriodStartUtc: string;\n MonthlyWithdrawnUsd: string; // decimal string\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number;\n RemainingUses?: number;\n Level?: number; // per-instance upgrade level (Item module's UpgradeLevel)\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for equip state\n CustomData?: string | null;\n}\n\n/** Daily-window counter for one conversion pair (\"{srcType}:{srcID}->{tgtType}:{tgtID}\"). */\ninterface ConversionDailyCounter {\n PeriodStartUtc: string;\n AmountToday: string; // decimal string\n}\n```\n\n`EquippedSlot` on an unstackable item instance is the authoritative record of\nwhat's equipped where — the Character module's per-character `Equipment` map\nis a convenience cache view kept in sync with this by the SDK.\n\nThe wire schema for `UserInventoryState` (`zUserInventoryState`) is currently\nlenient (`z.object({}).passthrough()` cast to the type) — Phase 1 of the\nstrict-typing port validated `ClientState`'s top-level shape only; inventory's\nfield-level schema is expected to tighten in a later phase. Don't rely on\nruntime validation catching a malformed inventory field today; the TS types\nare still accurate for what the backend currently sends.\n\n---\n\n## UsageTimeStats\n\nReturned by `getUsageTime()` (a fetch/response type — distinct from the\npersisted `UserUsageState` below).\n\n```ts\ninterface UsageTimeStats {\n Today: number;\n Yesterday: number;\n CurrentWeek: number;\n CurrentMonth: number;\n Total: number;\n TotalSessions: number;\n FirstActiveAt?: string | null;\n LastActiveAt?: string | null;\n LongestSessionEverSeconds?: number | null;\n CurrentWeekActiveHoursMask?: number | null;\n CurrentMonthActiveHoursMask?: number | null;\n ReactivationCount?: number | null;\n History?: Record<string, unknown> | null; // = Usage.Daily, i.e. the recent WINDOW — see below\n [k: string]: unknown;\n}\n```\n\nAll duration fields are seconds. `*ActiveHoursMask` is a bitmask (which hours\nof the day had activity) — decode per-bit if you need an activity heatmap.\n\n⚠ `History` is `Usage.Daily`, so it only covers the recent window (see\n`UserUsageState` below) — not the player's whole history. The folded\nmonthly/yearly totals are **not** in this response; read them from\n`UserState.Usage`. `Total`/`TotalSessions` here are lifetime and exact.\n\n## UserUsageState\n\nThe persisted shape on `UserState.Usage` (`UserDataDocument.Usage` server-side)\n— foreground-activity seconds only, aggregated per day.\n\n⚠ **History is rolled up: days → months → years, and the rollup is\nIRREVERSIBLE.** `Daily` used to hold every day the player had ever been active;\nit grew forever inside the document read on every request. It is now a WINDOW\nof the most recent days (400 by default, per-title configurable). Older days\nare folded into `Monthly`, and months of finished years into `Yearly`; the\nper-day detail outside the window is gone for good.\n\n**Do not sum `Daily` to get a lifetime or year-long total** — you will silently\nundercount. Use `TotalSeconds`/`TotalSessions` (kept separately, exact), or add\nthe period records.\n\n```ts\ninterface UserUsageState {\n TotalSeconds: number; // lifetime, never touched by the rollup\n TotalSessions: number; // lifetime, never touched by the rollup\n FirstActiveAt?: string | null;\n LastActiveAt: string;\n Reactivations?: UsageReactivationEvent[];\n Daily?: Record<string, DailyUsageRecord>; // key = \"ddMMyyyy\" (UTC) — RECENT WINDOW only\n Monthly?: Record<string, UsagePeriodRecord>; // key = \"yyyyMM\" (sortable, unlike the day key)\n Yearly?: Record<string, UsagePeriodRecord>; // key = \"yyyy\"\n}\n\n/** One folded period. `ActiveDays` is the reason the rollup exists: total seconds\n * can't tell you whether the player came daily or once, and per-day records are gone. */\ninterface UsagePeriodRecord {\n Seconds: number;\n Sessions: number;\n ActiveDays: number;\n LongestSessionSeconds: number;\n ActiveHoursMask: number;\n FirstActiveDay: string;\n LastActiveAt: string;\n}\n\n/** One recorded reactivation — a return after a silence gap of 7+ days. */\ninterface UsageReactivationEvent {\n ReactivatedAt: string;\n DaysSinceLastActive: number;\n}\n\ninterface DailyUsageRecord {\n Seconds: number;\n Sessions: number;\n LongestSessionSeconds: number;\n ActiveHoursMask: number;\n LastActiveAt: string;\n}\n```\n\n`UsageTimeStats` (from `getUsageTime()`) and `UserUsageState` (on `UserState.Usage`)\noverlap in intent but are separate types with separate field names — don't\nconflate `Today`/`CurrentWeek`/`CurrentMonth` (rolling windows in the fetch\nresponse) with `Daily` (a raw per-day map, keyed by date, in the persisted\nstate).\n\n---\n\n## Requests\n\n```ts\ninterface UserRequest extends BaseRequest {\n IsNewSession?: boolean; // addUsageTime\n SessionDurationSeconds?: number; // addUsageTime\n Fields?: string[]; // reserved — not currently populated by UserService\n TitleFields?: string[]; // reserved — not currently populated by UserService\n ExcludeFields?: string[]; // getClientStateExcept — User.* keys to preserve from cache\n ExcludeTitleFields?: string[]; // getClientStateExcept — Title.* keys to preserve from cache\n}\n```\n\n`BaseRequest` supplies `UserID`, `ClientSessionTicket`, `BuildKey`,\n`WebAppLink`, and a fresh `RelatedEntityID` per call (all filled in\nautomatically by `UserService`'s internal `baseRequest()` — you never build a\n`UserRequest` by hand). `Fields`/`TitleFields` exist on the request type but\n`UserService`'s methods never set them today — only `ExcludeFields` /\n`ExcludeTitleFields` are wired up, exclusively by `getClientStateExcept`.\n`BaseRequest` also carries `Username` and `UsageTime` (used by\n`changeUsername`/`addUsageTime` respectively) — those two live on the shared\n`BaseRequest` interface, not on `UserRequest` itself, since other modules'\nrequests (e.g. registration) also set `Username`.\n\n---\n\n## Backend rules (transcribed from `User.cs`)\n\n### Username validation (`ChangeUsername`)\n\nSource: `IDosGamesSDK/API/Client/v2/User/User.cs:245-267`.\n\n1. The server trims the incoming `Username` (`args.Username?.Trim()`).\n2. Rejects with the literal string `\"INVALID_USERNAME\"` if the trimmed value\n is null/whitespace, or its length is `< 3` or `> 24` characters. There is\n no character-set restriction beyond length — any non-whitespace string in\n range is accepted verbatim (no profanity filter, no uniqueness check).\n3. On success it does a single field patch\n (`Builders<UserDataDocument>.Update.Set(u => u.PublicData.Username, desired)`)\n directly via `ResourceService.PatchUserDataDocumentByIDAsync` — it does\n **not** go through `ResourceService`'s resource-operation/OCC path, so\n there's no idempotency-by-reason key for this call; a resend just\n overwrites the name again with the same (or a new) value.\n4. The response (`ChangeUsernameResponse.Username`) is the exact trimmed\n string that was stored — never a suffixed/deduplicated variant, since\n there's no uniqueness constraint to disambiguate against.\n\n### Delete-account cascade (`DeleteUserAccount`)\n\nSource: `IGSService.DeleteUserAccount` (`IDosGamesSDK/Core/CoreScripts/IGServer/Service/IGSService.cs:1275-1278`)\n→ `DataBaseService.DeleteAllDataUserAsync` (`IDosGamesSDK/Core/CoreScripts/IGServer/DataBase/DataBaseService.cs:498-519`).\n\n- The call is a single MongoDB `DeleteOneAsync` against the `USER_TYPE`\n collection, filtered by `UserID` **and** `TitleID` together. It deletes\n exactly one `UserDataDocument` — this title's per-player document only.\n- There is no fan-out to other collections in this code path: no explicit\n cleanup of Marketplace escrow/listings, leaderboard documents, coop-group\n membership, blockchain deposit-address records, or the cross-title\n `PlatformUserDocument`/`LinkedTitleAccounts` map. If a player is linked to a\n platform account, deleting one title's `UserDataDocument` does not unlink\n or delete the platform-level document or any other title's data.\n- The operation returns a plain `bool` (`DeletedCount > 0`); the endpoint\n turns a `false` into `OperationResult.Fail(\"Failed to Delete User Account\")`\n (`User.cs:269-275`). There is no soft-delete/undo flag anywhere in this\n path — a successful call is a permanent, synchronous hard delete of the row.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|