@idosgames/mcp 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -5
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +21 -17
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +5 -5
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/acquisition-attribution.json +6 -0
- package/registry/skills/authentication.json +3 -3
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/collection-system.json +1 -1
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/game-loop-system.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-title-bootstrap.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/lootbox-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +1 -1
- package/registry/skills/purchase-system.json +2 -2
- package/registry/skills/referral-system.json +3 -3
- package/registry/skills/reward-system.json +1 -1
- package/registry/skills/social-system.json +1 -1
- package/registry/skills/user-profile.json +2 -2
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "referral-system",
|
|
3
|
-
"description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (who they're subscribed to, follower count, claimed invite rewards), activate someone else's
|
|
4
|
-
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount` and `FollowerIDs`, and which `InviteRewards` they've\n claimed (`InviteRewardStates`). Fetched with `getUserState()`.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a deterministic `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount`/`FollowerIDs` are decremented/pulled, the new\n one incremented, and `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.FollowerIDs;\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
3
|
+
"description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (their own SHORT invite code, a ready-made invite link, who they're subscribed to, follower count, claimed invite rewards), activate someone else's code, and claim a staged invite reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants invite-friend / referral-code / refer-a-friend UIs, follower-milestone reward screens, or otherwise touches client.referral, ReferralService, ReferralDefinitions, UserReferralState, or referral codes — even if they don't name the module explicitly. Also use it for \"share my invite link\", \"enter a friend's code\" and invite-code screens.",
|
|
4
|
+
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (their own SHORT invite code, a ready-made invite link, who they're\n subscribed to, follower count, claimed invite rewards), activate someone\n else's code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly. Also use\n it for \"share my invite link\", \"enter a friend's code\" and invite-code\n screens.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player gets\na **short human code** (`WDJBMJHT`) and a ready-made **invite link**, a new\nplayer **activates** someone else's code once, the referrer's `FollowersCount`\ngoes up, and the referrer can later **claim** staged rewards as that count\ncrosses configured thresholds.\n\n> ⚠ **A code is NOT a `UserID`.** Older docs and older UI said it was, and it\n> used to be true. It is not any more: the code is 8 characters from a\n> confusable-free alphabet, minted per player per title, and a `UserID` is not\n> accepted in the code field at all. Never render a `UserID` as \"your code\".\n\nMost bindings never touch this module's UI: the SDK captures `?ref=`, an\ninvite link, a Telegram `start_param` and ad tags at launch and ships them with\nthe login, so the server binds the player before any screen is shown. Manual\nentry is the fallback for people who arrived without a link. That capture side\n— and the attribution it feeds — is the `acquisition-attribution` skill; this\none is only about the referral loop itself. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — this player's own `Code`, who\n they activated (`SubscribedToUserID`), whether their activation reward was\n granted, whether one is still `PendingActivationReward`, their own\n `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`, which also returns\n `InviteUrl` alongside it (on the response, not inside `Referral`).\n\n⚠ **`getUserState()` is not a pure read, and two things depend on that.** The\nserver mints the player's `Code` LAZILY on that call — most players never\ninvite anyone, so nobody gets a code until they open the screen — and it\nsettles `PendingActivationReward`, paying the activation reward to a player who\nwas bound at login rather than by typing a code. So: call it when you open the\ninvite screen, and do not serve that screen from a stale cache.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a **deterministic** `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nBecause the key is deterministic, a **retry of the same operation is a replay,\nnot a second grant**: `ResourceService` recognises the reason, returns the\nstored result and grants nothing again. Still disable the button while a call\nis in flight — that's about the player seeing what happened, not about\ndouble-paying.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to `data.ReferrerUserID` — the referrer's **id**, which\n the response carries separately from the code that was typed — clears\n `PendingActivationReward`, and applies `data.Resources` (the\n `ActivationReward`, only present when `IsFirstActivation` is true) to cached\n balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **The code must be well-formed before it is even looked up.** Eight\n characters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, no `0/O`, no `1/I/L`),\n separators ignored. Anything else — including a `UserID` — fails with\n `\"Referral code is invalid\"` without a database round-trip.\n- **An unknown but well-formed code** fails the same way.\n- **Self-referral is rejected** with `\"Cannot activate your own referral code\"`\n — the server resolves the code to a player first and compares ids, so a\n player pasting their own link gets this rather than a generic error.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount` is decremented, the new one incremented, and\n `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n// ⚠ There is no FollowerIDs — only the count. Activating a code also makes the\n// two players friends, so the identities are `client.social.getFriendsList()`.\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Show the player's own code and invite link\n\n```ts\nconst res = await client.referral.getUserState();\nif (!res.ok) return showLoadFailed(res.error); // NOT the same as \"no code yet\"\n\nconst code = res.data.Referral?.Code; // e.g. \"WDJBMJHT\"\nconst link = res.data.InviteUrl; // e.g. \"https://idosgames.com/go/MYTITLE?ref=WDJBMJHT\"\n\n// Group it for reading aloud; the server strips separators on the way back in.\nconst pretty =\n code?.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n```\n\nTake the link **as given**. Do not assemble one from a template: the server\nbuilds it precisely so a client cannot turn it into an open redirect, and so\nevery client words it identically. No `Code` means the title has no referral\nconfig at all — hide the sharing UI rather than showing an empty box.\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(pastedByPlayer);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID (the referrer's id) + the granted ActivationReward.\n```\n\nPass through **whatever the player pasted**: the SDK accepts a bare code, a\ncode with separators, and the whole invite link, in any case. People forward\nthe link they were sent far more often than they retype eight characters, and\nhandling that in each title's UI separately is how the two drift apart.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\n`SpendRewards` describes the _rules_ (rate, source/target currency, per\nfeature) and the backend applies them automatically: after a follower spends\nin Store, Marketplace, Lootbox, Craft, Item, Character, TimedBoost, Premium,\nCurrency or Reward, the referrer is credited.\n\nThere is still nothing to call from the referrer's side, and no `referral:*`\nevent fires for them: the payout lands in **another player's** document, in a\nrequest that player made. Read `defs?.SpendRewards` to show \"earn N% back when\nyour friends spend\", and show the balance itself from the referrer's own\nstate on their next request.\n\n## Gotchas\n\n- **`SpendRewards` is wired and pays.** (This entry used to claim the\n opposite; it was wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called\n from 12 modules after a successful deduction. What is still true: the\n referrer gets no event and no cache update from it, because the payout\n happens inside someone else's request. Their balance shows up on their next\n own request.\n- **The rate field is `Rate`, a FRACTION.** `0.05` means 5%. It is not\n `Percent`, and it is not `5`. A rule with no `Rate` is skipped silently — it\n neither pays nor errors.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **The code is a short human code (`WDJB-MJHT`), not a `UserID`.** The player\n reads it off a screen or gets it inside an invite link;\n `client.referral.activateReferralCode` accepts either the bare code or the\n whole link pasted. An internal identifier is not accepted at all.\n- **A code belongs to a TITLE, not to the platform.** It is stored under\n `{titleID}:{code}` and is unique only inside that title, which is why the\n invite link always carries the title (`/go/{titleID}?ref=...`) and why a\n link shaped like `/i/{code}` cannot exist.\n- **Most players never type it.** `AcquisitionCapture` (in `@idosgames/core`,\n wired automatically) reads `?ref=`, `idos_click`, Telegram `start_param` and\n ad tags at launch, keeps them across the login screen, and ships them with\n whichever sign-in the player uses. Manual entry is the fallback for people\n who arrived without a link. You do not call it and must not duplicate it.\n- **A player bound at login has an unpaid reward until the screen is opened.**\n That is what `PendingActivationReward` means: the login path has no per-user\n lock, so the server flags the debt and settles it on the next\n `getUserState()`. If your game never opens an invite screen, that reward is\n never paid — put the call somewhere the player reaches.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard; the rejection (`\"Cannot activate your own referral code\"`) only comes\n back after the round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **A double-clicked \"Activate\"/\"Claim\" does NOT pay twice.** The idempotency\n key is deterministic, so the repeat is a replay and grants nothing again.\n (This entry used to claim the opposite.) Disable the control while a call is\n in flight anyway — so the player can tell what happened.\n- **Input is normalised for you** — `activateReferralCode` pulls the code out\n of a pasted link, strips separators, trims and uppercases before sending. Do\n not pre-clean it yourself; a second implementation of the same rule is how\n \"this code is invalid\" starts happening to valid codes.\n- **`SubscribedToUserID` holds an ID, `Code` holds a code.** They are different\n fields with different shapes, and the activation response carries both\n (`ReferrerUserID` and `ReferralCode`). Rendering one where the other belongs\n is the single easiest mistake here.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Referral data model — reference\r\n\r\nFull shape of the config (`ReferralDefinitions`) and player state\r\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\r\nshared Core/Milestone progression-multiplier math that scales\r\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\r\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\r\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\r\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\r\nbackend adds later still round-trips. Field names are PascalCase (straight\r\nfrom the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\r\n- [SpendRewardDefinition](#spendrewarddefinition)\r\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\r\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\r\n- [Activation flow — server rules](#activation-flow--server-rules)\r\n- [Claim flow — server rules](#claim-flow--server-rules)\r\n\r\n---\r\n\r\n## Config: ReferralDefinitions\r\n\r\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\r\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\r\n\r\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\r\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\r\n\r\n```ts\r\ninterface ReferralDefinitions {\r\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\r\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\r\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\r\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\r\n}\r\n```\r\n\r\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\r\nthe same shared type used across every module (currencies, items, event\r\ntokens, premium-tier bundles). See the `currency-system` skill for its full\r\nshape if you need it.\r\n\r\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\r\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\r\n\r\n```ts\r\ninterface MilestoneDefinition {\r\n MilestoneID?: string;\r\n DisplayName?: string;\r\n AssetPaths?: Record<string, string>;\r\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\r\n Rewards?: ResourceGrant; // base payout\r\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\r\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\r\n SortOrder?: number;\r\n IsFeatured?: boolean;\r\n}\r\n```\r\n\r\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\r\n`BonusActive`/`SeasonChainID` context (see\r\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\r\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\r\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\r\nthough the type carries them for other modules.\r\n\r\n---\r\n\r\n## SpendRewardDefinition\r\n\r\n```ts\r\ninterface SpendRewardDefinition {\r\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\r\n IsEnabled?: boolean; // default true\r\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\r\n SourceCurrencyID?: string; // currency the follower spends\r\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\r\n}\r\n```\r\n\r\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\r\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\r\nsupposed to call after a successful deduction to compute and grant the\r\nkickback — but grepping the entire backend turns up **zero** definitions or\r\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\r\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\r\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\r\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\r\nevent tied to a follower's purchase — there is nothing to listen for.\r\n\r\n---\r\n\r\n## Player state: UserReferralState\r\n\r\nReturned by `getUserState()` as `{ Referral }`; cached at\r\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\r\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\r\n\r\n```ts\r\ninterface UserReferralState {\r\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\r\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\r\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\r\n FollowerIDs?: string[]; // UserIDs of those followers — kept in sync so a follower switching away can be pulled out correctly\r\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\r\n UpdatedAt?: string; // ISO timestamp of last change\r\n}\r\n\r\ninterface ReferralInviteRewardState {\r\n RewardID?: string; // == the MilestoneID key\r\n IsClaimed?: boolean;\r\n ClaimedAt?: string | null;\r\n}\r\n```\r\n\r\n`InviteRewardStates` only contains entries that have actually been claimed —\r\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\r\nmilestone systems); a milestone id absent from the map simply means \"not yet\r\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\r\n`RequiredProgress`.\r\n\r\nA player's referral code **is their own `UserID`** — the module has no\r\nseparate generated/short code. To let a player share \"their\" code, show them\r\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\r\nor endpoint for a display-friendly code.\r\n\r\n---\r\n\r\n## Invite-reward payout — the Milestone resolver\r\n\r\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\r\nverbatim. The backend runs it through the shared\r\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\r\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\r\ncontext (`Referral.cs`, `ClaimInviteReward`):\r\n\r\n```csharp\r\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\r\n{\r\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\r\n Player = doc,\r\n NowUtc = DateTime.UtcNow,\r\n});\r\n```\r\n\r\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\r\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\r\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\r\nbranches are always skipped for Referral. The **only** overlay that can ever\r\nchange an invite-reward payout is the title-wide progression multiplier:\r\n\r\n1. Read the title's `RewardProgressionMultiplierSpec` from\r\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\r\n also read — configured once per title, not per-module).\r\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\r\n scaling.\r\n3. Otherwise (`RewardProgressionResolver.cs`):\r\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\r\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\r\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\r\n **not** `FollowersCount` — the multiplier's progression axis is\r\n independent of the referral threshold you're claiming against.\r\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\r\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\r\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\r\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\r\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\r\n point the curve is the **identity**, so a player who has not reached the first\r\n tier gets no bonus.\r\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\r\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\r\n real zero. `NaN`/`Infinity` collapses to `1.0`.\r\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\r\n the resolver: a reward multiplier never reduces a reward unless the publisher\r\n says so explicitly.\r\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\r\n `null`, the grant is returned unscaled.\r\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\r\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\r\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\r\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\r\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\r\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\r\n left alone — they're applied later, after scaling, inside\r\n `ResourceService`.\r\n - **Rounding**: each scaled amount goes through the platform-wide\r\n `ModifierService.Apply` with a `Multiply` step, which finishes with\r\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\r\nmultiplier)`, never negative, never silently truncated down.\r\n\r\nTo preview this on the client before the player claims, call\r\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\r\nthe exact same spec/progress/rounding server-side and returns\r\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\r\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\r\nown copy of this multiplier — it's title-wide, not per-module.\r\n\r\n---\r\n\r\n## Activation flow — server rules\r\n\r\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\r\nin order:\r\n\r\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\r\n the SDK side before this is even sent).\r\n2. Trimmed + uppercased. If it equals the caller's own `UserID` (also\r\n uppercased): `\"Cannot activate your own referral code\"`.\r\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\r\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\r\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\r\n6. If the caller is already subscribed to that **same** code:\r\n `\"Referral code already activated\"`.\r\n7. Otherwise the call **succeeds**, whether or not the player had a previous\r\n referrer:\r\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\r\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\r\n the caller's id is pulled from their `FollowerIDs`.\r\n - The new referrer's `FollowersCount` is atomically incremented and the\r\n caller's id added to `FollowerIDs` (`$addToSet`, so re-adding is a\r\n no-op).\r\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\r\n two as mutual friends (capped by the Social module's friend limit;\r\n silently skipped if either side is already at the cap).\r\n - `IsFirstActivation` is `true` only when the caller had **no** previous\r\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\r\n When true, `ActivationReward` is granted via\r\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\r\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\r\n set permanently — a later referrer switch will not re-grant it.\r\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\r\n guarding against a concurrent change (matches \"no previous referrer\" or\r\n \"still the previously-read referrer\"); if that races, the call fails\r\n with `\"Referral state was modified concurrently. Please retry.\"` and the\r\n client should just retry.\r\n\r\n## Claim flow — server rules\r\n\r\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\r\norder:\r\n\r\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\r\n2. Must exist in `config.Referral.InviteRewards`, else\r\n `\"Invite reward '{id}' not found in configuration\"`.\r\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\r\n `\"Not enough followers. Required: {n}, current: {m}\"`.\r\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\r\n5. The resolved grant (see above) is applied atomically with idempotency key\r\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\r\n only allows the write when there's no existing claimed state for that\r\n reward id (protects against a double-claim race the same way step 3/4\r\n protect against a stale read).\r\n"
|
|
8
|
+
"content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Lootbox\", \"Reward\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Rate?: number; // FRACTION: 0.05 = 5%. Never 0..100. Empty or 0 = the rule pays nothing.\n Basis?: string; // \"Spend\" (default) or \"Earn\" — what the share is taken from\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n MinSourceAmount?: number; // ignore operations below this. 0 = no floor\n MaxRewardPerOperation?: number; // cap the payout from ONE operation. 0 = no cap\n Limits?: LimitSpec; // anti-farm caps for the REFERRER: DailyCap, DailyWeightCap\n}\n```\n\n⚠ **`FeatureKey` is not free text.** The accepted values are listed in\n`ReferralV2.SpendFeatureKeys`; an unknown key never matches, so the rule stays\nsilent rather than erroring. `\"Gacha\"` is **not** one of them — the lootbox key\nis `\"Lootbox\"`.\n\n⚠ **The field is `Rate` and it is a fraction.** It used to be `Percent` holding\n0..100. A dashboard that wrote the old shape had it dropped at deserialisation\nand every rule it created silently paid nobody — no error anywhere. If you\nrender a percentage, convert at the edge and keep the wire value a fraction.\n\n**This is wired and it pays.** (This section used to claim the opposite; that\nwas wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called from 12 modules\nafter a successful deduction. What remains true is the client-side\nconsequence: the referrer gets **no event and no cache update**, because the\npayout happens inside a request made by _another_ player. Their balance simply\nreflects it on their next own request. So describe the rule in UI (\"earn N%\nback when friends spend\"), but do not build a claim or notification flow — the\ngrant is automatic and there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n Code?: string | null; // THIS player's own short code, e.g. \"WDJBMJHT\". Minted lazily on the first getUserState()\n PendingActivationReward?: boolean; // bound at login, reward not paid yet — settled by the next getUserState()\n SubscribedToUserID?: string | null; // the referrer's USER ID (not their code); null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code\n // ⚠ FollowerIDs was REMOVED: an unbounded list of UserIDs inside the player document that\n // nothing read. Only the count remains. Activating a code also makes the two players friends,\n // so the identities are reachable as a friends list (client.social.getFriendsList()).\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface ReferralInviteRewardState {\n RewardID?: string; // == the MilestoneID key\n IsClaimed?: boolean;\n ClaimedAt?: string | null;\n}\n```\n\n`InviteRewardStates` only contains entries that have actually been claimed —\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\nmilestone systems); a milestone id absent from the map simply means \"not yet\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\n`RequiredProgress`.\n\n### The code and the invite link\n\nA player's code is a **short generated string**, not their `UserID`: 8\ncharacters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, so no accidental words; no\n`0/O`, no `1/I/L`, so nothing is misread when dictated). It lives in\n`UserReferralState.Code`.\n\n- **Minted lazily**, on the first `getUserState()`. Most players never invite\n anyone, and giving every registration a row in the code collection would be\n a write per signup for a field nobody reads.\n- **Scoped to the TITLE**, stored as `{titleID}:{code}`. Two titles may hand\n out the same code to different people, which is why every invite link\n carries the title.\n- The ready-made link comes back **on the response**, as\n `UserReferralStateResponse.InviteUrl` — `idosgames.com/go/{titleID}?ref={code}`.\n The server builds it; a client-built link from a template would be an open\n redirect. It is absent whenever `Code` is.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\n point the curve is the **identity**, so a player who has not reached the first\n tier gets no bonus.\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\n real zero. `NaN`/`Infinity` collapses to `1.0`.\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\n the resolver: a reward multiplier never reduces a reward unless the publisher\n says so explicitly.\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\n `null`, the grant is returned unscaled.\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\n left alone — they're applied later, after scaling, inside\n `ResourceService`.\n - **Rounding**: each scaled amount goes through the platform-wide\n `ModifierService.Apply` with a `Multiply` step, which finishes with\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\nmultiplier)`, never negative, never silently truncated down.\n\nTo preview this on the client before the player claims, call\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\nthe exact same spec/progress/rounding server-side and returns\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\nown copy of this multiplier — it's title-wide, not per-module.\n\n---\n\n## Activation flow — server rules\n\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\nin order:\n\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\n the SDK side before this is even sent).\n2. **Shape checked before any lookup**: 8 characters from\n `BCDFGHJKMNPQRSTVWXZ`, separators ignored, case-insensitive. Anything else\n — a `UserID` included — is `\"Referral code is invalid\"` without touching\n the database.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The normalised code must resolve to a real player in THIS title, else\n `\"Referral code is invalid\"`. Resolving to the caller themselves is\n `\"Cannot activate your own referral code\"` — the comparison is on ids,\n after the lookup, so pasting your own link gives the specific message\n rather than a generic one.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n (`FollowerIDs` no longer exists — only the count is kept).\n - The new referrer's `FollowersCount` is atomically incremented.\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\n two as mutual friends (capped by the Social module's friend limit;\n silently skipped if either side is already at the cap).\n - `IsFirstActivation` is `true` only when the caller had **no** previous\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\n When true, `ActivationReward` is granted via\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\n set permanently — a later referrer switch will not re-grant it.\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\n guarding against a concurrent change (matches \"no previous referrer\" or\n \"still the previously-read referrer\"); if that races, the call fails\n with `\"Referral state was modified concurrently. Please retry.\"` and the\n client should just retry.\n\n## Claim flow — server rules\n\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\norder:\n\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\n2. Must exist in `config.Referral.InviteRewards`, else\n `\"Invite reward '{id}' not found in configuration\"`.\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\n `\"Not enough followers. Required: {n}, current: {m}\"`.\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\n5. The resolved grant (see above) is applied atomically with idempotency key\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\n only allows the write when there's no existing claimed state for that\n reward id (protects against a double-claim race the same way step 3/4\n protect against a stale read).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Reward data model — reference\r\n\r\nFull shape of the config (Definitions) and player state for all four\r\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\r\nand the milestone reward multiplier curve. The config side is **strictly\r\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\r\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\r\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\r\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\r\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\r\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\r\nlater still round-trips. The deeper nested shapes shown below as plain\r\n`interface` blocks in this doc (`DailyCalendarDefinition`,\r\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\r\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\r\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\r\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\r\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\r\ntypes — most of them are **not individually exported by name** from\r\n`@idosgames/core`'s public entry point today; don't write `import type {\r\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\r\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\r\nstyle indexed-access types if you need the standalone name). Per-user state\r\nobjects beyond the top-level four dictionaries are typed as lenient\r\npassthrough shapes on the SDK side — the fields documented below are what the\r\nbackend actually puts on them. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\n## Contents\r\n\r\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\r\n- [Tier-reward settings](#tier-reward-settings)\r\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\r\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\r\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\r\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\r\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\r\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\r\n\r\n---\r\n\r\n## Root config: RewardDefinitions\r\n\r\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\r\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\r\n`RewardDefinitions.cs`.\r\n\r\n```ts\r\ninterface RewardDefinitions {\r\n TierRewards?: TierRewardSettings | null;\r\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\r\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\r\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\r\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\r\n Claims?: Record<string, ClaimRewardDefinition> | null;\r\n}\r\n```\r\n\r\nEach of the four dictionaries is an **independent subsystem** — a title can\r\nuse only some of them; an empty/absent dictionary just means that subsystem is\r\noff. All four grant rewards through the same `ResourceGrant`, so premium\r\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\r\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\r\n\r\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\r\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\r\n\r\n```ts\r\ninterface UserRewardState {\r\n DailyCalendars?: Record<string, UserDailyCalendarState>;\r\n IdleAccruals?: Record<string, UserIdleAccrualState>;\r\n Comebacks?: Record<string, UserComebackState>;\r\n Claims?: Record<string, UserClaimRewardState>;\r\n}\r\n```\r\n\r\nAn absent entry in any of the four dictionaries means \"player never touched\r\nthis ID\" — the server treats it as default/zero state, not an error.\r\n\r\n---\r\n\r\n## Tier-reward settings\r\n\r\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\r\nrewards resolve across _every_ system that has tiers (premium, season, battle\r\npass, etc.), not just Reward itself. One mode per title.\r\n\r\n```ts\r\ninterface TierRewardSettings {\r\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\r\n RewardStackLowerTiers?: boolean; // default: false\r\n}\r\n```\r\n\r\n- `Additive` — tier rewards are added **on top of** the base reward.\r\n- `Replace` — tier rewards **fully replace** the base reward.\r\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\r\n `false` (default) — only the best matching tier applies.\r\n\r\nThis block is read by `ResourceService`, not by Reward's own claim logic\r\ndirectly — it's here because `RewardDefinitions` is where it's configured.\r\n\r\n---\r\n\r\n## Daily calendars\r\n\r\n### Config: `DailyCalendarDefinition`\r\n\r\n```ts\r\ninterface DailyCalendarDefinition {\r\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\r\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\r\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\r\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\r\n MissThresholdMultiplier?: number; // default 2.0\r\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\r\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\r\n Gate?: SegmentGate; // null/empty = everyone\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface DailyRewardDay {\r\n DayNumber?: number; // 1-based, unique per calendar\r\n Rewards?: ResourceGrant;\r\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserDailyCalendarState`\r\n\r\n```ts\r\ninterface UserDailyCalendarState {\r\n CalendarID?: string;\r\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\r\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\r\n}\r\n```\r\n\r\n### Claim eligibility (`ClaimMode`)\r\n\r\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\r\n\r\n- **`CalendarDayUtc`** (default): a new claim is available once\r\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\r\n `\"Daily reward already claimed today for this calendar\"` if the player\r\n already claimed on today's UTC date. Ignores player timezone.\r\n- **`SlidingWindow`**: a new claim is available once\r\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\r\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\r\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\r\n\r\n### Miss detection and `MissBehavior`\r\n\r\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\r\nfirst, before the new day is computed.\r\n\r\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\r\n- **Miss condition** (was the gap too large?):\r\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\r\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\r\n- **On miss**, `CollectedDays` becomes:\r\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\r\n rewards are forfeited, the streak count itself survives).\r\n - `ResetToStart` — `0` (hard streak reset).\r\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\r\n at 0).\r\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\r\n\r\n### Day resolution\r\n\r\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\r\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\r\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\r\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\r\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\r\nlooping — only the _day looked up_ wraps, not the counter).\r\n\r\nDefault-calendar resolution when `calendarID` is omitted: the server uses\r\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\r\nback to the first entry in the dictionary.\r\n\r\n---\r\n\r\n## Idle accruals\r\n\r\n### Config: `IdleAccrualDefinition`\r\n\r\n```ts\r\ninterface IdleAccrualDefinition {\r\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Rate?: IdleRateConfig;\r\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\r\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\r\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\r\n Requirements?: IdleAccrualRequirements;\r\n FirstClaimMode?:\r\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface IdleRateConfig {\r\n BaseRatePerSecond: number; // flat, unconditional\r\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\r\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\r\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\r\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\r\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\r\n}\r\n\r\ninterface PremiumTierMultiplier {\r\n MinPremiumTier?: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n Multiplier?: number;\r\n}\r\n\r\ninterface IdleAccrualRequirements {\r\n MinCharacterLevel: number; // 0 = not checked\r\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\r\n Gate?: SegmentGate; // null/empty = not checked\r\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\r\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\r\n}\r\n```\r\n\r\n### State: `UserIdleAccrualState`\r\n\r\n```ts\r\ninterface UserIdleAccrualState {\r\n AccrualID?: string;\r\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\r\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\r\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\r\n}\r\n```\r\n\r\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\r\n\r\n```\r\nrawRate = BaseRatePerSecond\r\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\r\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\r\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\r\n\r\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\r\n MinPremiumTier <= player's MaxActiveTier (and matching\r\n RequiredPremiumID if set); 1.0 if none match or premium is null\r\n — multipliers never stack.\r\n\r\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\r\n```\r\n\r\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\r\n\r\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\r\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\r\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\r\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\r\nsite yet. The config fields exist and round-trip, but don't promise \"gear\r\nboosts idle income\" in product copy until this is verified live via\r\n`AppliedRatePerSecond` in a real claim response.\r\n\r\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\r\n\r\n```\r\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\r\n = otherwise, resolved by FirstClaimMode:\r\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\r\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\r\n\r\nelapsedSeconds = max(0, now - effectiveStart) in seconds\r\naccruedSeconds = MaxAccumulationSeconds > 0\r\n ? min(elapsedSeconds, MaxAccumulationSeconds)\r\n : elapsedSeconds\r\n```\r\n\r\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\r\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\r\nTry again in {n}s\"`.\r\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\r\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\r\n zero-payout finalize — sets `LastCollectAt = now`, returns\r\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\r\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\r\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\r\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\r\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\r\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\r\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\r\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\r\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\r\n unscaled and are applied afterward by `ResourceService` as usual.\r\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\r\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\r\n\r\n### Requirements gate (checked every claim, not persisted)\r\n\r\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\r\n\r\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\r\nhigher premium tier\"`.\r\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\r\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\r\n'{id}' level {n} is below required {m}\"`.\r\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\r\n `\"Required item '{id}' is not in inventory\"`.\r\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\r\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\r\n'{charID}'\"`.\r\n\r\nFailing a requirement does **not** move `LastCollectAt` — once the\r\nrequirement is met again, the previously-accrued time (up to the cap) is still\r\ncollectible.\r\n\r\n---\r\n\r\n## Comeback rewards\r\n\r\n### Config: `ComebackRewardDefinition`\r\n\r\n```ts\r\ninterface ComebackRewardDefinition {\r\n ComebackID?: string; // key in Comebacks; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Tiers?: ComebackTier[];\r\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\r\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\r\n TrackPresenceOnRead?: boolean; // default true\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ComebackTier {\r\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\r\n Rewards?: ResourceGrant;\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserComebackState`\r\n\r\n```ts\r\ninterface UserComebackState {\r\n ComebackID?: string;\r\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\r\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\r\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\r\n}\r\n```\r\n\r\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\r\n\r\nRuns on **every** claim call for this comeback, and also on\r\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\r\n\r\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\r\n stop — no absence to evaluate yet.\r\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\r\n both set): if `ClaimWindowSeconds > 0` and\r\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\r\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\r\n means it never expires on its own.)\r\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\r\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\r\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\r\n ties broken by taking the higher threshold). If a tier matches AND the\r\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\r\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\r\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\r\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\r\n\r\nThe tier is deliberately locked at the **moment of return**, not at claim\r\ntime — this stops a player from delaying the claim to try to \"grow into\" a\r\nricher tier.\r\n\r\n### Claim (`RewardV2.ClaimComebackReward`)\r\n\r\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\r\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\r\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\r\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\r\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\r\ntimestamp, so a stale pending anchor from a concurrent request can't be\r\ndouble-spent.\r\n\r\n---\r\n\r\n## Claim rewards\r\n\r\n### Config: `ClaimRewardDefinition`\r\n\r\n```ts\r\ninterface ClaimRewardDefinition {\r\n ClaimID?: string; // key in Claims; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\r\n Rewards?: ResourceGrant;\r\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\r\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ClaimLimitOverride {\r\n MinPremiumTier: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\r\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\r\n WindowSeconds?: number | null;\r\n TotalClaimLimit?: number | null;\r\n}\r\n```\r\n\r\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\r\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\r\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\r\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\r\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\r\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\r\n\r\n### State: `UserClaimRewardState`\r\n\r\n```ts\r\ninterface UserClaimRewardState {\r\n ClaimID?: string;\r\n TotalClaims: number; // monotonically increasing; never resets\r\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n}\r\n```\r\n\r\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\r\n\r\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\r\nthe player has an active premium tier, the **one** override with the highest\r\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\r\noverrides never stack. Each of that override's four fields is applied only if\r\nnon-null; a null field falls back to the base `Limits` value, not to \"no\r\nlimit.\"\r\n\r\n### Claim validation order (`RewardV2.PrepareClaimReward`)\r\n\r\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\r\nclaimable by client (server-only)\"`), and `Rewards` is configured.\r\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\r\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\r\n4. Resolve effective limits (base + best override).\r\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\r\n `\"Total claim limit reached ({have}/{limit})\"`.\r\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\r\n `\"Reward is on cooldown. Try again in {n}s\"`.\r\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\r\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\r\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\r\n{window}s)\"`.\r\n8. On success, `now` is appended to the window list, then the list is\r\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\r\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\r\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\r\n first). `TotalClaims` increments by 1 regardless of window/cooldown\r\n settings.\r\n\r\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\r\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\r\nclient `claimReward` call against one is always rejected.\r\n\r\n### Batch claiming (backend-only today)\r\n\r\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\r\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\r\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\r\nindependently (invalid/ineligible ids are filtered out and reported before any\r\ncharge), then applies the merged valid set as a single atomic operation with\r\none combined `Resources` payload attached to the first successful result\r\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\r\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\r\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\r\nso this path is not reachable from the TS client yet.\r\n\r\n---\r\n\r\n## Milestone reward multiplier\r\n\r\n`RewardDefinitions.MilestoneRewardMultiplier` is a\r\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\r\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\r\nfour subsystems; it's a title-wide overlay that other milestone-bearing\r\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\r\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\r\n_last_ overlay in their reward-resolution chain.\r\n\r\n```ts\r\ninterface RewardProgressionMultiplierSpec {\r\n Source?: ProgressionSource; // metric the multiplier is driven by\r\n SourceKey?: string; // disambiguator when Source needs one\r\n Curve?: ScalarCurveSpec; // the curve; base 1 unless Base is set. Empty = no scaling\r\n Anchor?: number; // progress value the curve starts counting from; empty = 0\r\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\r\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\r\n}\r\n```\r\n\r\n`ProgressionSource` values (from `MilestoneModels.ts` /\r\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\r\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\r\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\r\n\r\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\r\n\r\nThe eight fields that used to describe the curve here (`CurveType`, `Tiers`, `TierMode`,\r\n`BaseMultiplier`, `PerUnit`, `MinMultiplier`, `MaxMultiplier`) collapsed into one shared\r\n`ScalarCurveSpec`:\r\n\r\n| Old shape | Now |\r\n| --- | --- |\r\n| `CurveType: \"Tiered\"` + `Tiers` | `Shape: \"Table\"` with `Points: [{ AtStep, Value }]` |\r\n| `TierMode: \"Step\" \\| \"Linear\"` | `Interpolation: \"Step\" \\| \"Linear\"` (also `\"Geometric\"`) |\r\n| `CurveType: \"Linear\"` + `PerUnit` | `Shape: \"PerStepRate\"` (share of the base per unit) |\r\n| `BaseMultiplier` | `Base` (empty = 1, i.e. a multiplier that changes nothing) |\r\n| `MinMultiplier` / `MaxMultiplier` | `MinResult` / `MaxResult` — **empty means NO bound**, and `0` now means a real zero |\r\n\r\n```\r\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\r\n\r\nraw = evaluateCurve(spec.Curve, base = 1.0, step = progress, firstStep = spec.Anchor ?? 0)\r\nfinal = NaN/Infinity -> 1.0\r\n```\r\n\r\n⚠ **The floor \"a reward multiplier never REDUCES a reward\" is no longer a config field.**\r\nIt is a domain rule of the resolver: when the publisher sets no `MinResult`, the result is\r\nfloored at `1.0`. Deliberate reduction is expressed by a curve that DOES set `MinResult`\r\nbelow 1 — so it can only happen on purpose, never by a stray zero.\r\n\r\n⚠ **Before the first table point a curve is the IDENTITY, not the first point's value.**\r\nA player who has not reached the first tier gets no bonus at all.\r\n\r\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\r\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\r\nlive `Multiplier`, the raw `Progress` value read from the player's current\r\nprogression state, and echoes of `Source`/`SourceKey`.\r\n\r\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\r\n\r\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\r\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\r\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\r\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\r\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\r\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\r\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\r\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\r\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\r\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\r\nMatching for items is by `ItemID`; for currencies/event-tokens, by\r\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\r\nuntouched by this step — they're applied afterward, on top of the\r\nalready-scaled `Standard` bundle, by `ResourceService`.\r\n\r\n---\r\n\r\n## Shared plumbing\r\n\r\nThese blocks are reused by all four subsystems (and the rest of the\r\nplatform) — full details live in their own modules; summarized here only as\r\nthey affect Reward.\r\n\r\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\r\n used by `Gate` fields on `DailyCalendarDefinition`,\r\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\r\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\r\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\r\n a failing gate always surfaces as `reason: \"server\"` with a\r\n \"locked behind a higher premium tier\"-style message — there is no\r\n client-visible breakdown of _which_ gate condition failed.\r\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\r\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\r\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\r\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\r\n shared type but ignored by `RewardV2`.\r\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\r\n subsystem's `Rewards` field and every claim response's `data.Resources` use\r\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\r\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\r\n amount by the time it reaches the client.\r\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\r\n one of the four definition types follow the same rule:\r\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\r\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\r\n both may be absent for \"no bound.\"\r\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\r\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\r\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\r\n the three id-taking methods (not `claimDailyReward`'s optional\r\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\r\n trip for the common typo case.\r\n"
|
|
8
|
+
"content": "# Reward data model — reference\n\nFull shape of the config (Definitions) and player state for all four\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\nand the milestone reward multiplier curve. The config side is **strictly\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\nlater still round-trips. The deeper nested shapes shown below as plain\n`interface` blocks in this doc (`DailyCalendarDefinition`,\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\ntypes — most of them are **not individually exported by name** from\n`@idosgames/core`'s public entry point today; don't write `import type {\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\nstyle indexed-access types if you need the standalone name). Per-user state\nobjects beyond the top-level four dictionaries are typed as lenient\npassthrough shapes on the SDK side — the fields documented below are what the\nbackend actually puts on them. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\n- [Tier-reward settings](#tier-reward-settings)\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\n\n---\n\n## Root config: RewardDefinitions\n\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\n`RewardDefinitions.cs`.\n\n```ts\ninterface RewardDefinitions {\n TierRewards?: TierRewardSettings | null;\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\n Claims?: Record<string, ClaimRewardDefinition> | null;\n}\n```\n\nEach of the four dictionaries is an **independent subsystem** — a title can\nuse only some of them; an empty/absent dictionary just means that subsystem is\noff. All four grant rewards through the same `ResourceGrant`, so premium\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\n\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\n\n```ts\ninterface UserRewardState {\n DailyCalendars?: Record<string, UserDailyCalendarState>;\n IdleAccruals?: Record<string, UserIdleAccrualState>;\n Comebacks?: Record<string, UserComebackState>;\n Claims?: Record<string, UserClaimRewardState>;\n}\n```\n\nAn absent entry in any of the four dictionaries means \"player never touched\nthis ID\" — the server treats it as default/zero state, not an error.\n\n---\n\n## Tier-reward settings\n\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\nrewards resolve across _every_ system that has tiers (premium, season, battle\npass, etc.), not just Reward itself. One mode per title.\n\n```ts\ninterface TierRewardSettings {\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\n RewardStackLowerTiers?: boolean; // default: false\n}\n```\n\n- `Additive` — tier rewards are added **on top of** the base reward.\n- `Replace` — tier rewards **fully replace** the base reward.\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\n `false` (default) — only the best matching tier applies.\n\nThis block is read by `ResourceService`, not by Reward's own claim logic\ndirectly — it's here because `RewardDefinitions` is where it's configured.\n\n---\n\n## Daily calendars\n\n### Config: `DailyCalendarDefinition`\n\n```ts\ninterface DailyCalendarDefinition {\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\n MissThresholdMultiplier?: number; // default 2.0\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\n Gate?: SegmentGate; // null/empty = everyone\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface DailyRewardDay {\n DayNumber?: number; // 1-based, unique per calendar\n Rewards?: ResourceGrant;\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserDailyCalendarState`\n\n```ts\ninterface UserDailyCalendarState {\n CalendarID?: string;\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\n}\n```\n\n### Claim eligibility (`ClaimMode`)\n\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\n\n- **`CalendarDayUtc`** (default): a new claim is available once\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\n `\"Daily reward already claimed today for this calendar\"` if the player\n already claimed on today's UTC date. Ignores player timezone.\n- **`SlidingWindow`**: a new claim is available once\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\n\n### Miss detection and `MissBehavior`\n\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\nfirst, before the new day is computed.\n\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\n- **Miss condition** (was the gap too large?):\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\n- **On miss**, `CollectedDays` becomes:\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\n rewards are forfeited, the streak count itself survives).\n - `ResetToStart` — `0` (hard streak reset).\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\n at 0).\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\n\n### Day resolution\n\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\nlooping — only the _day looked up_ wraps, not the counter).\n\nDefault-calendar resolution when `calendarID` is omitted: the server uses\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\nback to the first entry in the dictionary.\n\n---\n\n## Idle accruals\n\n### Config: `IdleAccrualDefinition`\n\n```ts\ninterface IdleAccrualDefinition {\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Rate?: IdleRateConfig;\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\n Requirements?: IdleAccrualRequirements;\n FirstClaimMode?:\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface IdleRateConfig {\n BaseRatePerSecond: number; // flat, unconditional\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\n}\n\ninterface PremiumTierMultiplier {\n MinPremiumTier?: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n Multiplier?: number;\n}\n\ninterface IdleAccrualRequirements {\n MinCharacterLevel: number; // 0 = not checked\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\n Gate?: SegmentGate; // null/empty = not checked\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\n}\n```\n\n### State: `UserIdleAccrualState`\n\n```ts\ninterface UserIdleAccrualState {\n AccrualID?: string;\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\n}\n```\n\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\n\n```\nrawRate = BaseRatePerSecond\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\n\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\n MinPremiumTier <= player's MaxActiveTier (and matching\n RequiredPremiumID if set); 1.0 if none match or premium is null\n — multipliers never stack.\n\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\n```\n\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\n\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\nsite yet. The config fields exist and round-trip, but don't promise \"gear\nboosts idle income\" in product copy until this is verified live via\n`AppliedRatePerSecond` in a real claim response.\n\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\n\n```\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\n = otherwise, resolved by FirstClaimMode:\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\n\nelapsedSeconds = max(0, now - effectiveStart) in seconds\naccruedSeconds = MaxAccumulationSeconds > 0\n ? min(elapsedSeconds, MaxAccumulationSeconds)\n : elapsedSeconds\n```\n\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\nTry again in {n}s\"`.\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\n zero-payout finalize — sets `LastCollectAt = now`, returns\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\n unscaled and are applied afterward by `ResourceService` as usual.\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\n\n### Requirements gate (checked every claim, not persisted)\n\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\n\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\nhigher premium tier\"`.\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\n'{id}' level {n} is below required {m}\"`.\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\n `\"Required item '{id}' is not in inventory\"`.\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\n'{charID}'\"`.\n\nFailing a requirement does **not** move `LastCollectAt` — once the\nrequirement is met again, the previously-accrued time (up to the cap) is still\ncollectible.\n\n---\n\n## Comeback rewards\n\n### Config: `ComebackRewardDefinition`\n\n```ts\ninterface ComebackRewardDefinition {\n ComebackID?: string; // key in Comebacks; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tiers?: ComebackTier[];\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\n TrackPresenceOnRead?: boolean; // default true\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ComebackTier {\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\n Rewards?: ResourceGrant;\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserComebackState`\n\n```ts\ninterface UserComebackState {\n ComebackID?: string;\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\n LastClaimAt: string; // ISO; MinValue = never claimed\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\n}\n```\n\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\n\nRuns on **every** claim call for this comeback, and also on\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\n\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\n stop — no absence to evaluate yet.\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\n both set): if `ClaimWindowSeconds > 0` and\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\n means it never expires on its own.)\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\n ties broken by taking the higher threshold). If a tier matches AND the\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\n\nThe tier is deliberately locked at the **moment of return**, not at claim\ntime — this stops a player from delaying the claim to try to \"grow into\" a\nricher tier.\n\n### Claim (`RewardV2.ClaimComebackReward`)\n\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\ntimestamp, so a stale pending anchor from a concurrent request can't be\ndouble-spent.\n\n---\n\n## Claim rewards\n\n### Config: `ClaimRewardDefinition`\n\n```ts\ninterface ClaimRewardDefinition {\n ClaimID?: string; // key in Claims; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\n Rewards?: ResourceGrant;\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ClaimLimitOverride {\n MinPremiumTier: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\n WindowSeconds?: number | null;\n TotalClaimLimit?: number | null;\n}\n```\n\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\n\n### State: `UserClaimRewardState`\n\n```ts\ninterface UserClaimRewardState {\n ClaimID?: string;\n TotalClaims: number; // monotonically increasing; never resets\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\n LastClaimAt: string; // ISO; MinValue = never claimed\n}\n```\n\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\n\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\nthe player has an active premium tier, the **one** override with the highest\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\noverrides never stack. Each of that override's four fields is applied only if\nnon-null; a null field falls back to the base `Limits` value, not to \"no\nlimit.\"\n\n### Claim validation order (`RewardV2.PrepareClaimReward`)\n\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\nclaimable by client (server-only)\"`), and `Rewards` is configured.\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\n4. Resolve effective limits (base + best override).\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\n `\"Total claim limit reached ({have}/{limit})\"`.\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\n `\"Reward is on cooldown. Try again in {n}s\"`.\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\n{window}s)\"`.\n8. On success, `now` is appended to the window list, then the list is\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\n first). `TotalClaims` increments by 1 regardless of window/cooldown\n settings.\n\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\nclient `claimReward` call against one is always rejected.\n\n### Batch claiming (backend-only today)\n\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\nindependently (invalid/ineligible ids are filtered out and reported before any\ncharge), then applies the merged valid set as a single atomic operation with\none combined `Resources` payload attached to the first successful result\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\nso this path is not reachable from the TS client yet.\n\n---\n\n## Milestone reward multiplier\n\n`RewardDefinitions.MilestoneRewardMultiplier` is a\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\nfour subsystems; it's a title-wide overlay that other milestone-bearing\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\n_last_ overlay in their reward-resolution chain.\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: ProgressionSource; // metric the multiplier is driven by\n SourceKey?: string; // disambiguator when Source needs one\n Curve?: ScalarCurveSpec; // the curve; base 1 unless Base is set. Empty = no scaling\n Anchor?: number; // progress value the curve starts counting from; empty = 0\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\n`ProgressionSource` values (from `MilestoneModels.ts` /\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\n\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\n\nThe eight fields that used to describe the curve here (`CurveType`, `Tiers`, `TierMode`,\n`BaseMultiplier`, `PerUnit`, `MinMultiplier`, `MaxMultiplier`) collapsed into one shared\n`ScalarCurveSpec`:\n\n| Old shape | Now |\n| --------------------------------- | ----------------------------------------------------------------------------------- |\n| `CurveType: \"Tiered\"` + `Tiers` | `Shape: \"Table\"` with `Points: [{ AtStep, Value }]` |\n| `TierMode: \"Step\" \\| \"Linear\"` | `Interpolation: \"Step\" \\| \"Linear\"` (also `\"Geometric\"`) |\n| `CurveType: \"Linear\"` + `PerUnit` | `Shape: \"PerStepRate\"` (share of the base per unit) |\n| `BaseMultiplier` | `Base` (empty = 1, i.e. a multiplier that changes nothing) |\n| `MinMultiplier` / `MaxMultiplier` | `MinResult` / `MaxResult` — **empty means NO bound**, and `0` now means a real zero |\n\n```\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\n\nraw = evaluateCurve(spec.Curve, base = 1.0, step = progress, firstStep = spec.Anchor ?? 0)\nfinal = NaN/Infinity -> 1.0\n```\n\n⚠ **The floor \"a reward multiplier never REDUCES a reward\" is no longer a config field.**\nIt is a domain rule of the resolver: when the publisher sets no `MinResult`, the result is\nfloored at `1.0`. Deliberate reduction is expressed by a curve that DOES set `MinResult`\nbelow 1 — so it can only happen on purpose, never by a stray zero.\n\n⚠ **Before the first table point a curve is the IDENTITY, not the first point's value.**\nA player who has not reached the first tier gets no bonus at all.\n\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\nlive `Multiplier`, the raw `Progress` value read from the player's current\nprogression state, and echoes of `Source`/`SourceKey`.\n\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\n\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\nMatching for items is by `ItemID`; for currencies/event-tokens, by\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\nuntouched by this step — they're applied afterward, on top of the\nalready-scaled `Standard` bundle, by `ResourceService`.\n\n---\n\n## Shared plumbing\n\nThese blocks are reused by all four subsystems (and the rest of the\nplatform) — full details live in their own modules; summarized here only as\nthey affect Reward.\n\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\n used by `Gate` fields on `DailyCalendarDefinition`,\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\n a failing gate always surfaces as `reason: \"server\"` with a\n \"locked behind a higher premium tier\"-style message — there is no\n client-visible breakdown of _which_ gate condition failed.\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\n shared type but ignored by `RewardV2`.\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\n subsystem's `Rewards` field and every claim response's `data.Resources` use\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\n amount by the time it reaches the client.\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\n one of the four definition types follow the same rule:\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\n both may be absent for \"no bound.\"\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\n the three id-taking methods (not `claimDailyReward`'s optional\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\n trip for the common typo case.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -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
|
}
|