@idosgames/mcp 0.1.16 → 0.1.18
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 +1 -1
- package/package.json +1 -1
- package/registry/host.json +19 -7
- package/registry/index.json +731 -33
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/character-ui.json +78 -0
- package/registry/modules/chat-ui.json +74 -0
- package/registry/modules/craft-ui.json +74 -0
- package/registry/modules/deal-offer-ui.json +73 -0
- package/registry/modules/events-ui.json +80 -0
- package/registry/modules/friends-ui.json +73 -0
- package/registry/modules/game-hud.json +15 -10
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory-ui.json +73 -0
- package/registry/modules/leaderboard-ui.json +75 -0
- package/registry/modules/marketplace-ui.json +75 -0
- package/registry/modules/profile-ui.json +74 -0
- package/registry/modules/pvp-ui.json +72 -0
- package/registry/modules/referral-ui.json +73 -0
- package/registry/modules/rewards-ui.json +75 -0
- package/registry/modules/season-ui.json +79 -0
- package/registry/modules/showcase.json +92 -0
- package/registry/modules/store-ui.json +75 -0
- package/registry/modules/tutorial-ui.json +71 -0
- package/registry/modules/voxelcraft.json +98 -41
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +6 -0
- package/registry/skills/leaderboard-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +3 -3
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Leaderboard data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nmechanics, score aggregation, rank/milestone rewards, brackets, and batch\nsemantics. All of these are **strictly typed in the SDK** —\n`LeaderboardDefinitions` and every nested block (`LeaderboardDefinition`,\n`LeaderboardRankReward`, `LeaderboardChainedEvent`, the shared `ScheduleSpec`/\n`SegmentGate`/`MilestoneDefinition`/`TriggerSource`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<LeaderboardDefinitions>(\"Leaderboard\")` give you concrete types,\nnot `unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what's cached from `getMyProgress()` / batch reads\n- [Config: LeaderboardDefinitions](#config-leaderboarddefinitions) — what `getDefinitions()` returns\n- [LeaderboardDefinition](#leaderboarddefinition)\n- [Score aggregation](#score-aggregation)\n- [Schedule modes: Cyclic vs Scheduled vs Chained](#schedule-modes-cyclic-vs-scheduled-vs-chained)\n- [Rank rewards](#rank-rewards)\n- [Milestones & claim modes](#milestones--claim-modes)\n- [Brackets](#brackets)\n- [Score sources (ScoreSources / submitScoreFromSource)](#score-sources-scoresources--submitscorefromsource)\n- [Responses](#responses)\n- [Batch semantics](#batch-semantics)\n\n---\n\n## Player state\n\nProgress is cached per cycle/instance doc at\n`client.data.user.state?.Leaderboard?.ProgressByCycle[cycleDocID]`. For a\n`Cyclic` leaderboard, `cycleDocID` is `` `${titleID}_${leaderboardID}` ``\n(built by the service — never construct it yourself). On the backend, a\n`Scheduled`/`Chained` leaderboard uses a per-instance doc ID\n(`{base}:{instanceKey}`) instead; the SDK's client-facing methods operate on\nthe _current_ instance implicitly, so you normally don't need to know that\nsuffix — read progress back via the response's `LeaderboardID` if you need to\nscan the map.\n\n```ts\ninterface UserLeaderboardProgress {\n CurrentScore?: number; // this player's current score in the active cycle\n ScoreEarnedThisCycle?: number; // sum of submissions since the cycle started\n ClaimedMilestoneIDs?: string[]; // milestone ids already claimed this cycle\n ScoreCycleVersion?: number; // cycle version this progress belongs to\n LastScoreSubmitUtc?: string; // ISO timestamp of the last submitScore\n UnclaimedRewardCycleVersion?: number; // >0 = a rank reward is waiting; 0 = none/claimed\n LastClaimedCycleVersion?: number;\n LastKnownRank?: number; // rank as of the last getMyProgress/getLeaderboard read\n BracketID?: string | null; // which bracket this player is in, if BracketSize is set\n}\n\ninterface UserLeaderboardsState {\n ProgressByCycle?: Record<string, UserLeaderboardProgress>;\n}\n```\n\nCache-write rules the SDK applies for you (`packages/core/src/cache/UserData.ts`,\nports of the backend's `PatchLeaderboard*` progress mutations):\n\n- **`submitScore` / `submitScoreFromSource`** → `patchLeaderboardScoreSubmitted`:\n if the response's `CycleVersion` is newer than the cached\n `ScoreCycleVersion`, the cache resets `ScoreEarnedThisCycle` to 0, clears\n `ClaimedMilestoneIDs`, and clears `BracketID` (new cycle, clean slate)\n _before_ applying the new submission. `CurrentScore` is then always set to\n the response's `NewScore`; `ScoreEarnedThisCycle` accumulates the submitted\n amount (regardless of aggregation mode — it tracks _submissions_, not the\n aggregated score); `ScoreCycleVersion` and `LastScoreSubmitUtc` are updated\n unconditionally. Note `submitScoreFromSource`'s benign no-op response\n (`NewScore: 0, CycleVersion: 0` when no source matched, or the leaderboard\n isn't `Sum`-aggregated) still runs through this patch — it sets\n `CurrentScore` to 0 and touches `LastScoreSubmitUtc`, but with\n `CycleVersion: 0` it will not trigger the newer-cycle reset branch on a\n leaderboard whose cached version is already `>= 0`.\n- **`getMyProgress` / `getProgressBatch` / `getOverviewBatch`** →\n `patchLeaderboardMyProgress`: overwrites `CurrentScore`, `LastKnownRank`,\n `ScoreEarnedThisCycle` from the response, and sets\n `UnclaimedRewardCycleVersion` to `max(1, previous)` if `HasUnclaimedReward`\n is true, else resets it to 0.\n- **`claimCycleReward` / `claimCycleRewardsBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardCycleRewardClaimed`: zeroes `UnclaimedRewardCycleVersion`.\n Requires an existing progress entry (a no-op if the cache never saw this\n leaderboard).\n- **`claimMilestone` / `claimMilestonesBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardMilestoneClaimed`: appends the milestone id to\n `ClaimedMilestoneIDs` (dedup'd).\n\n---\n\n## Config: LeaderboardDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<LeaderboardDefinitions>(\"Leaderboard\")`.\n\n```ts\ninterface LeaderboardDefinitions {\n Definitions?: Record<string, LeaderboardDefinition>; // key = LeaderboardID\n}\n```\n\n---\n\n## LeaderboardDefinition\n\nOne title leaderboard: identity, schedule, gate, brackets, rank rewards,\nmilestones, and what feeds it a score. Source: `LeaderboardDefinition.cs`\n(`IDosGamesSDK/API/Client/v2/Leaderboard/Models/LeaderboardDefinitions.cs`).\n\n```ts\ninterface LeaderboardDefinition {\n LeaderboardID: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null; // label for the score column, e.g. \"Kills\", \"Time\"\n AssetPaths?: Record<string, string> | null;\n IsEnabled?: boolean | null; // default true; false rejects reads/writes\n\n ScoreAggregation?: string | null; // Sum | BestScore | BestTime | LastValue; default Sum\n Schedule?: ScheduleSpec | null; // schedule/instance container (shared block); default Mode=Cyclic\n ChainEvents?: LeaderboardChainedEvent[] | null; // phases, when Schedule.Mode === \"Chained\"\n Gate?: SegmentGate | null; // who can see/participate (shared block)\n\n TopUsersLimit?: number | null; // max entries stored/returned in the top list; default 100\n MinScoreToEnterTop?: number | null; // score floor to appear in the top list; default 1\n BracketSize?: number | null; // players per bracket; default 0 = one global board\n\n LinkedSeasonChainID?: string | null; // ties rank/milestone rewards to a Season chain's tiers\n\n RankRewards?: LeaderboardRankReward[] | null;\n Milestones?: Record<string, MilestoneDefinition> | null; // key = MilestoneID\n\n MilestoneClaimMode?: string | null; // Instant | AfterEventEnd | FeaturedAfterEnd; default Instant\n MilestoneClaimGraceHours?: number | null; // claim window after cycle/instance end; default 0\n\n ScoreSources?: TriggerSource[] | null; // what `submitScoreFromSource` matches against\n}\n```\n\nA leaderboard with `IsEnabled: false` rejects reads/writes with\n`\"Leaderboard '{id}' is disabled\"` — check it before showing the screen.\n`Gate` is the same `SegmentGate` used across the SDK (segments, premium tier,\nlevel range, country, experiment variant — see `.claude/skills/premium-system/SKILL.md`\nfor how `MinPremiumTier` is evaluated) — all conditions are AND-ed,\nempty/absent gate = open to everyone. A player failing the gate gets\n`\"Leaderboard '{id}' is not available for you\"` from both `getLeaderboard`\nand `submitScore`.\n\n---\n\n## Score aggregation\n\n`ScoreAggregation` controls what `submitScore`'s `NewScore` becomes relative\nto the player's existing score in the current cycle\n(`LeaderboardV2.AggregateScore`):\n\n| Mode | Behavior |\n| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Sum` | Adds the submitted value to the running total. |\n| `BestScore` | Keeps the higher of the two (`Math.Max(current, incoming)`). |\n| `BestTime` | Keeps the lower of the two (`Math.Min`), except the very first submission (`current == 0`) always wins outright — a `0` current score never beats a real time. |\n| `LastValue` | Always overwrites with the latest submission. |\n\nThis math is backend-owned — the SDK never recomputes it; always read\n`NewScore` from `SubmitScoreResponse` rather than predicting it client-side.\n`ScoreEarnedThisCycle` in the cache tracks the sum of raw submissions\nregardless of aggregation mode (useful for milestone progress even under\n`BestScore`/`LastValue`, where `CurrentScore` itself may not move on every\nsubmit). Top-list sort order (`LeaderboardV2.SortTopUsers`) mirrors this:\n`BestTime` sorts ascending by score (lower = better), everything else sorts\ndescending; ties break by whoever reached that score first\n(`LastScoreUpdateUtc` ascending, both for `BestTime` and the other modes).\n\n`ScoreSources`-driven accrual (`submitScoreFromSource`, and all server-side\nboard/marketplace score grants) is accumulative by nature and is therefore\n**only wired up for `Sum`-aggregated leaderboards** — see\n[Score sources](#score-sources-scoresources--submitscorefromsource).\n\n---\n\n## Schedule modes: Cyclic vs Scheduled vs Chained\n\n`Schedule` is the shared `ScheduleSpec` block (same primitive used by\nTimedEvent, TimedBoost, CoopEvent, Season, DealOffer). For leaderboards only\nthree `Mode` values matter in practice:\n\n```ts\ninterface ScheduleSpec {\n Mode?: string; // Cyclic | Scheduled | Chained | AlwaysOn | Triggered | Relative\n IsActive?: boolean;\n Scheduled?: ScheduledWindow; // fixed StartUtc/EndUtc window (+ ClaimGraceHours, AllowEarningAfterEnd)\n Chain?: ScheduleChain; // recurring phase-chain timing (AnchorUtc, PauseBetweenPhasesSec, PauseBetweenCyclesSec)\n Cyclic?: CyclicSchedule; // calendar-aligned reset (Reset: Daily/Weekly/.../Never, + FixedInterval fields)\n ActivationTriggers?: TriggerSource[]; // unused by Leaderboard (Triggered mode not resolved here)\n Relative?: RelativeWindow; // unused by Leaderboard\n}\n```\n\nEach mode gives the leaderboard a different **instance identity** — this is\nthe single most important thing to get right when building UI around\n`CycleIndex`/`ChainedEventID`/grace windows:\n\n- **`Cyclic`** (default) — one leaderboard, an endless series of cycles keyed\n by a calendar-aligned `CyclicSchedule.Reset` (`Daily`/`Weekly`/`Monthly`/...\n /`Never`/`FixedInterval`). The doc ID is fixed\n (`{titleID}_{leaderboardID}`); what moves is `CycleVersion`, incremented\n each reset. `submitScore`, `getMyProgress`, `claimCycleReward`, and\n `claimMilestone` all operate on the _current_ cycle implicitly — you never\n pass a cycle selector for normal play. Past cycles are only reachable via\n `ClaimableGraceCycles` (see [Milestones](#milestones--claim-modes)) and via\n `LeaderboardRequest.CycleKey` on `claimMilestone` — the SDK's\n `claimMilestone(leaderboardID, milestoneID)` does not currently expose a\n `CycleKey` parameter, so client code can only claim grace-window milestones\n the SDK's typed method surfaces for the _current_ call shape; treat this as\n a backend capability documented for completeness rather than something the\n bundled recipes exercise beyond what's already reachable from\n `ClaimableGraceCycles`.\n- **`Scheduled`** — a single fixed window (`Schedule.Scheduled.StartUtc`/\n `EndUtc`). There is exactly one instance, ever, for that config. Before\n `StartUtc` or after `EndUtc + ClaimGraceHours` there is no active instance\n (`getLeaderboard`/`getOverviewBatch` return an empty top list;\n `submitScore` fails with `\"has no active instance right now\"`).\n- **`Chained`** — a repeating sequence of named phases (`ChainEvents`, each\n with `Order`/`DurationSec`/`ClaimGraceHours`), running back-to-back with\n `Schedule.Chain.PauseBetweenPhasesSec` between phases and\n `PauseBetweenCyclesSec` between full loops of the chain. Each\n `(CycleIndex, ChainedEventID)` pair is an **independent leaderboard\n instance** — its own top list, its own `RankRewards`/`Milestones` slate\n (shared config, independent progress), its own bracket set, its own token\n bucket. `LeaderboardRequest` (and hence some backend actions) can address a\n specific past phase via `CycleIndex`/`ChainedEventID`, but the SDK's\n everyday methods (`submitScore`, `getMyProgress`, `claimCycleReward`,\n `claimMilestone`) implicitly target the _current_ active phase; you\n encounter `ChainedEventID`/`CycleIndex` values mainly when reading them\n back off `LeaderboardGraceCycle` for a phase that just ended.\n\nFor all three modes, `CycleVersion` on `SubmitScoreResponse`/\n`GetLeaderboardResponse` is what the SDK cache compares against the cached\n`ScoreCycleVersion` to detect \"this is a new cycle, reset local per-cycle\nstate\" — for `Scheduled`/`Chained` instances this version is always `1`\n(instance identity lives in the doc ID, not the version number).\n\n`ScheduledWindow.ClaimGraceHours` / each `LeaderboardChainedEvent\n.ClaimGraceHours` set how long after that instance ends its rank reward and\nmilestones stay claimable — mirrors the `MilestoneClaimGraceHours` concept\ndescribed in [Milestones](#milestones--claim-modes) but is a _per-instance_\nwindow for Scheduled/Chained rather than the leaderboard-wide\n`MilestoneClaimGraceHours` used by Cyclic.\n\n---\n\n## Rank rewards\n\n```ts\ninterface LeaderboardRankReward {\n Rank?: string; // rank range: \"1\", \"2-5\", \"6-10\", \"11-50\", ...\n Rewards?: ResourceGrant; // grant-only — no cost side\n SeasonTierRewards?: SeasonTierRewardSet; // optional season-tier scaling overlay\n}\n```\n\n`Rank` is a string range matched against the player's final rank when the\ncycle/instance closes (`LeaderboardV2.IsRankInRange`: an exact integer like\n`\"1\"`, or an inclusive `\"start-end\"` range). `claimCycleReward` looks up\nwhich range the player's `Rank` (from `ClaimCycleRewardResponse.Rank`) falls\ninto and pays out that entry's `Rewards`. If `LinkedSeasonChainID` is set,\n`SeasonTierRewards` can scale or replace the base reward based on the\nplayer's tier in that season chain, resolved from a **snapshot**\n(`LeaderboardUserEntry.SeasonTierAtCycleEnd`) taken when the cycle/instance\nwas archived — not the player's current tier — so a season reset between\ncycle-end and claim doesn't change what a past cycle pays out. (`Bundles`\nreplace on match, `Multipliers` scale the base `Rewards.Amounts` — same\noverlay mechanic as `MilestoneDefinition.SeasonTierRewards`.)\n\nA player only has something to claim when `HasUnclaimedReward` (from\n`getMyProgress`) is true, mirrored into the cache as\n`UnclaimedRewardCycleVersion > 0`. If the player wasn't in the final top list,\nor their rank has no matching `RankRewards` entry, or the resolved grant is\nempty after premium filtering, the backend auto-marks the cycle as claimed\nwithout granting anything (`\"No unclaimed reward\"` on a later claim attempt,\nor a normal `claimCycleReward` success with an empty `Resources`) — don't\ntreat either as an error state to alarm the player over.\n\n---\n\n## Milestones & claim modes\n\n`Milestones` uses the SDK-wide `MilestoneDefinition` primitive (shared with\nQuest, TimedEvent, CoopEvent, DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // threshold checked against accumulated progress\n Rewards?: ResourceGrant;\n BonusRewards?: ResourceGrant; // paid during a bonus window, if applicable\n SeasonTierRewards?: SeasonTierRewardSet;\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nFor leaderboards, progress toward a milestone is tracked by a dedicated\n**leaderboard event token** (`EventTokenType.Leaderboard`, address\n`\"{leaderboardID}:{cycleKey}\"` for Cyclic or `\"{leaderboardID}:{instanceKey}\"`\nfor Scheduled/Chained) — its cumulative `Balance.TotalEarned` is the\nscore-earning activity in the current cycle/instance\n(`ScoreEarnedThisCycle` / the response's `MilestoneTokensEarned`), separate\nfrom the ranked `CurrentScore`. A `BestScore` leaderboard where you resubmit\na lower score still accrues milestone progress from the submission, because\nthe token accrues every submitted amount regardless of aggregation.\n\n`MilestoneClaimMode` controls _when_ a reached milestone can be claimed\n(`IDosGames.MilestoneClaimMode`, shared enum — **note the exact member\nnames**, they are not `\"AfterCycleEnd\"`):\n\n- **`Instant`** — claimable as soon as it's reached, any time during the\n cycle/instance, via `claimMilestone` / `claimMilestonesBatch`.\n- **`AfterEventEnd`** — every milestone must wait until the cycle/instance\n closes, and then only within `MilestoneClaimGraceHours` (Cyclic) or the\n instance's own `ClaimGraceHours` (Scheduled/Chained) of the close. Claiming\n early fails with `\"Milestone can only be claimed after cycle ends\"` (Cyclic)\n or `\"...after instance ends\"` (Scheduled/Chained).\n- **`FeaturedAfterEnd`** — hybrid: milestones with `IsFeatured: false` claim\n `Instant`-style any time; milestones with `IsFeatured: true` behave like\n `AfterEventEnd` (`\"Featured milestone can only be claimed after cycle\nends\"`).\n\n`GetMyProgressResponse.ClaimableGraceCycles` lists past cycles/instances\nstill inside the claim window:\n\n```ts\ninterface LeaderboardGraceCycle {\n CycleKey?: string;\n CycleEndUtc?: string;\n ClaimDeadlineUtc?: string; // CycleEndUtc + grace hours\n MilestoneTokensEarned?: number;\n ClaimableMilestoneIDs?: string[]; // milestones from that cycle/instance still claimable\n CycleIndex?: number; // Chained only\n ChainedEventID?: string; // Chained only\n}\n```\n\nOnce `ClaimDeadlineUtc` passes, that cycle/instance's milestones are no\nlonger claimable — check `ClaimableGraceCycles` (and its per-cycle deadline)\nrather than assuming a reached-but-unclaimed milestone stays claimable\nindefinitely. `MilestoneClaimGraceHours: 0` (the default) means Cyclic grace\nis disabled entirely — once a cycle rolls over, its milestones are gone for\ngood.\n\n---\n\n## Brackets\n\nWhen `BracketSize` is set (default `0` = disabled), the leaderboard splits\nplayers into fixed-size brackets rather than one global ranking (keeps ranks\nmeaningful at scale). A player is assigned to a bracket on their **first\nscore submission of the cycle**; resubmissions in the same cycle update their\nscore within that same bracket. A player's current bracket is `BracketID` on\nboth the cached progress and `GetLeaderboardResponse.BracketID`. `BracketID`\nresets to `null` whenever the cache detects a new cycle (see the score-submit\nreset rule above) — a new cycle reshuffles brackets. Bracket assignment\nretries up to 5 times server-side under contention (multiple players racing\nto fill the last slot of a bracket); in the rare case all retries are\nexhausted the player is left without a bracket for that cycle and sees an\nempty top list until their next submission.\n\n---\n\n## Score sources (ScoreSources / submitScoreFromSource)\n\n`ScoreSources: TriggerSource[]` lets a leaderboard accrue score automatically\nfrom game events, instead of relying solely on an explicit `submitScore`\ncall — the same unified `TriggerSource` primitive TimedEvent uses for token\nsources:\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // e.g. \"DailyLogin\", \"ReferralInvite\", \"CustomAction\", board/quest/store/marketplace types\n BaseWeight?: number; // score granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's rollMultiplier/volume\n TileTypeFilter?: string[];\n TileIndexFilter?: number[];\n ChanceOutcomeFilter?: string[];\n OutcomeFilter?: string[];\n Params?: Record<string, string>;\n Limits?: LimitSpec;\n}\n```\n\nTwo independent gates apply, both verified in `LeaderboardV2.cs`:\n\n1. **Aggregation gate** — `ScoreSources` are cumulative deltas\n (`BaseScore × multiplier`), so they're only ever matched/applied on\n `Sum`-aggregated leaderboards. On any other `ScoreAggregation`, both the\n server-side board/marketplace grant builders and\n `submitScoreFromSource` silently skip (no score change, no error) — for\n `BestScore`/`BestTime`/`LastValue` leaderboards, score must come from an\n explicit `submitScore` with the real value (a time, a high score), not\n accumulate off game events.\n2. **Client/server source gate** — `submitScoreFromSource` (the client-facing\n gateway) rejects a fixed list of **server-only** source types outright\n with `\"Source '{type}' cannot be called from client.\"`: all\n `Board*` sources (`BoardTileLanding`, `BoardPassStart`, `BoardAttack`,\n `BoardRaid`, `BoardBuild`, `BoardStageComplete`, `BoardSpecialComplete`),\n `QuestComplete`, `StorePurchase`, `MilestoneReward`,\n `LeaderboardRankReward`, `MarketplaceSell`, `MarketplaceBuy`. Those are\n wired into leaderboards automatically by the corresponding server feature\n (GameLoop rolls, Marketplace settlements) — you don't and can't call them\n from the client. Client-safe sources like `DailyLogin`, `ReferralInvite`,\n or `CustomAction` (with your own `Params`) are the ones meant for\n `submitScoreFromSource`.\n\nIf no configured `ScoreSources` entry matches the given `sourceType`/\n`outcome`/`params` (or `BaseWeight <= 0`), the call is a **benign no-op**:\n`ok: true` with `{ NewScore: 0, CycleVersion: 0 }`, not an error — the\nbackend deliberately doesn't leak \"does this source exist\" information via\nan error. Don't render a `NewScore` toast without checking it's `> 0`.\n\n---\n\n## Responses\n\n```ts\ninterface LeaderboardUserEntry {\n UserID: string;\n Score?: number;\n Rank?: number;\n LastScoreUpdateUtc?: string;\n IpHash?: string;\n PublicProfile?: UserPublicDataModel; // display name/avatar for rendering the row\n SeasonTierAtCycleEnd?: number; // this entry's season tier when the cycle closed (archive snapshot only; 0 while cycle is active)\n}\n\ninterface GetLeaderboardResponse {\n LeaderboardID: string;\n CycleVersion?: number;\n TotalParticipants?: number;\n CycleEndUtc?: string;\n TopUsers?: LeaderboardUserEntry[]; // length capped at TopUsersLimit\n BracketID?: string; // set when BracketSize splits the board\n}\n\ninterface GetMyProgressResponse {\n LeaderboardID: string;\n CurrentScore?: number;\n LastKnownRank?: number;\n HasUnclaimedReward?: boolean;\n ScoreEarnedThisCycle?: number;\n MilestoneTokensEarned?: number;\n NextMilestone?: MilestoneDefinition; // next unreached milestone, or absent if none left\n HasUnclaimedMilestone?: boolean;\n ClaimableGraceCycles?: LeaderboardGraceCycle[];\n}\n\ninterface SubmitScoreResponse {\n LeaderboardID: string;\n NewScore: number;\n CycleVersion: number;\n}\n\ninterface ClaimCycleRewardResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n Rank?: number;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimLeaderboardMilestoneResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n MilestoneID: string;\n Resources?: ResourceOperation;\n}\n\ninterface LeaderboardOverview {\n LeaderboardID: string;\n Leaderboard?: BatchItemResult<GetLeaderboardResponse> | null;\n Progress?: BatchItemResult<GetMyProgressResponse> | null;\n}\n\ninterface ClaimAllRewardsResult {\n LeaderboardID: string;\n CycleReward?: BatchItemResult<ClaimCycleRewardResponse> | null;\n Milestones?: BatchItemResult<ClaimLeaderboardMilestoneResponse>[] | null;\n}\n```\n\n---\n\n## Batch semantics\n\nAll batch/array-of-IDs inputs (`LeaderboardIDs`, `Milestones` pairs, `Scores`\npairs) go through the same normalization on the backend\n(`BatchSupport.NormalizeIDs` / equivalent inline dedup in\n`SubmitScoresBatch`/`ClaimMilestonesBatch`): entries are trimmed, empty/\ninvalid ones dropped, duplicates removed (first occurrence wins, by ID for\nsingle-value batches or by the natural composite key — `LeaderboardID` for\nscores, `` `${LeaderboardID}:${MilestoneID}` `` for milestone pairs), then the\nlist is **clamped to 50 items**. Anything past the 50th is **silently\ndropped** — it never appears in the result array at all, success or failure.\nThe SDK does not pre-validate, warn, or chunk this for you.\n\n**Atomicity is per-subset, not per-call**, and this is the biggest surprise\nrelative to other v2 modules' batch actions:\n\n- `submitScoresBatch`: leaderboards that are `Cyclic` **and** not bracketed\n (`BracketSize == 0`) are all folded into a **single Mongo transaction** —\n either every one of those items' score+top-list update commits, or none\n do. Leaderboards that are bracketed, or `Scheduled`/`Chained`, are instead\n processed **one at a time** by internally re-calling the same code path as\n the single `submitScore` action — each such item is its own independent\n operation, can fail on its own, and does not affect the atomic group's\n outcome (or vice versa).\n- `claimCycleRewardsBatch` / `claimMilestonesBatch` / `claimAllRewardsBatch`:\n same split. `Cyclic` (non-bracketed or bracketed — bracket archives are\n just a different lookup, still part of the cyclic path) targets are merged\n into **one atomic claim** (`ApplyResourceOperationAtomicAsync` with all\n their patches/filters ANDed and their `Resources` grants merged — see\n `ApplyClaimBatchAsync`/`BatchSupport.ApplyMergedClaimAsync`). `Scheduled`/\n `Chained` targets are each claimed independently (one atomic operation\n _per instance_, since instances have no shared version to gate a joint\n transaction on), and for `claimAllRewardsBatch`/`claimCycleRewardsBatch` a\n non-cyclic leaderboard may internally claim **several ended-but-in-grace\n instances at once**, merging their resources into one aggregated\n `BatchItemResult` entry.\n- Where a merged claim did pay out resources, the combined `ResourceOperation`\n is attached to only the **first** successful item that had a non-empty\n grant in each atomic group (`resourcesAttached` flag) — don't expect every\n successful item in a multi-leaderboard claim to carry its own slice of\n `Resources`; read the aggregate off whichever item has it and trust the\n SDK's applied-balance side effects for the rest.\n- Invalid items (unknown leaderboard/milestone, \"already claimed\", \"no\n unclaimed reward\", timing violations) are filtered out **before** the\n atomic transaction is built, so one stale/bad entry in a batch never blocks\n the others from committing.\n\nIdempotency for single calls follows the platform-wide `RelatedEntityID`\npattern (`ResourceService.ResolveRelatedEntityID`): cycle\nclaims key off `lb_cycle_{leaderboardID}_v{cycleVersion}` (or\n`lb_inst_{instanceDocID}` for Scheduled/Chained), milestone claims key off\n`lb_milestone_{tokenEntityID}_{milestoneID}`. Batch claims build one\ncombined, deterministic reason from the sorted keys of everything actually\nincluded in that transaction (`BatchSupport.BuildBatchReason`) — retries with\nthe same effective item set are idempotent; changing the item set changes the\nreason.\n"
|
|
8
|
+
"content": "# Leaderboard data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nmechanics, score aggregation, rank/milestone rewards, brackets, and batch\nsemantics. All of these are **strictly typed in the SDK** —\n`LeaderboardDefinitions` and every nested block (`LeaderboardDefinition`,\n`LeaderboardRankReward`, `LeaderboardChainedEvent`, the shared `ScheduleSpec`/\n`SegmentGate`/`MilestoneDefinition`/`TriggerSource`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<LeaderboardDefinitions>(\"Leaderboard\")` give you concrete types,\nnot `unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what's cached from `getMyProgress()` / batch reads\n- [Config: LeaderboardDefinitions](#config-leaderboarddefinitions) — what `getDefinitions()` returns\n- [LeaderboardDefinition](#leaderboarddefinition)\n- [Score aggregation](#score-aggregation)\n- [Schedule modes: Cyclic vs Scheduled vs Chained](#schedule-modes-cyclic-vs-scheduled-vs-chained)\n- [Rank rewards](#rank-rewards)\n- [Milestones & claim modes](#milestones--claim-modes)\n- [Brackets](#brackets)\n- [Score sources (ScoreSources / submitScoreFromSource)](#score-sources-scoresources--submitscorefromsource)\n- [Responses](#responses)\n- [Batch semantics](#batch-semantics)\n\n---\n\n## Player state\n\nProgress is cached per cycle/instance doc at\n`client.data.user.state?.Leaderboard?.ProgressByCycle[cycleDocID]`. For a\n`Cyclic` leaderboard, `cycleDocID` is `` `${titleID}_${leaderboardID}` ``\n(built by the service — never construct it yourself). On the backend, a\n`Scheduled`/`Chained` leaderboard uses a per-instance doc ID\n(`{base}:{instanceKey}`) instead; the SDK's client-facing methods operate on\nthe _current_ instance implicitly, so you normally don't need to know that\nsuffix — read progress back via the response's `LeaderboardID` if you need to\nscan the map.\n\n```ts\ninterface UserLeaderboardProgress {\n CurrentScore?: number; // this player's current score in the active cycle\n ScoreEarnedThisCycle?: number; // sum of submissions since the cycle started\n ClaimedMilestoneIDs?: string[]; // milestone ids already claimed this cycle\n ScoreCycleVersion?: number; // cycle version this progress belongs to\n LastScoreSubmitUtc?: string; // ISO timestamp of the last submitScore\n UnclaimedRewardCycleVersion?: number; // >0 = a rank reward is waiting; 0 = none/claimed\n LastClaimedCycleVersion?: number;\n LastKnownRank?: number; // rank as of the last getMyProgress/getLeaderboard read\n BracketID?: string | null; // which bracket this player is in, if BracketSize is set\n}\n\ninterface UserLeaderboardsState {\n ProgressByCycle?: Record<string, UserLeaderboardProgress>;\n}\n```\n\nCache-write rules the SDK applies for you (`packages/core/src/cache/UserData.ts`,\nports of the backend's `PatchLeaderboard*` progress mutations):\n\n- **`submitScore` / `submitScoreFromSource`** → `patchLeaderboardScoreSubmitted`:\n if the response's `CycleVersion` is newer than the cached\n `ScoreCycleVersion`, the cache resets `ScoreEarnedThisCycle` to 0, clears\n `ClaimedMilestoneIDs`, and clears `BracketID` (new cycle, clean slate)\n _before_ applying the new submission. `CurrentScore` is then always set to\n the response's `NewScore`; `ScoreEarnedThisCycle` accumulates the submitted\n amount (regardless of aggregation mode — it tracks _submissions_, not the\n aggregated score); `ScoreCycleVersion` and `LastScoreSubmitUtc` are updated\n unconditionally. Note `submitScoreFromSource`'s benign no-op response\n (`NewScore: 0, CycleVersion: 0` when no source matched, or the leaderboard\n isn't `Sum`-aggregated) still runs through this patch — it sets\n `CurrentScore` to 0 and touches `LastScoreSubmitUtc`, but with\n `CycleVersion: 0` it will not trigger the newer-cycle reset branch on a\n leaderboard whose cached version is already `>= 0`.\n- **`getMyProgress` / `getProgressBatch` / `getOverviewBatch`** →\n `patchLeaderboardMyProgress`: overwrites `CurrentScore`, `LastKnownRank`,\n `ScoreEarnedThisCycle` from the response, and sets\n `UnclaimedRewardCycleVersion` to `max(1, previous)` if `HasUnclaimedReward`\n is true, else resets it to 0.\n- **`claimCycleReward` / `claimCycleRewardsBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardCycleRewardClaimed`: zeroes `UnclaimedRewardCycleVersion`.\n Requires an existing progress entry (a no-op if the cache never saw this\n leaderboard).\n- **`claimMilestone` / `claimMilestonesBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardMilestoneClaimed`: appends the milestone id to\n `ClaimedMilestoneIDs` (dedup'd).\n\n---\n\n## Config: LeaderboardDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<LeaderboardDefinitions>(\"Leaderboard\")`.\n\n```ts\ninterface LeaderboardDefinitions {\n Definitions?: Record<string, LeaderboardDefinition>; // key = LeaderboardID\n}\n```\n\n---\n\n## LeaderboardDefinition\n\nOne title leaderboard: identity, schedule, gate, brackets, rank rewards,\nmilestones, and what feeds it a score. Source: `LeaderboardDefinition.cs`\n(`IDosGamesSDK/API/Client/v2/Leaderboard/Models/LeaderboardDefinitions.cs`).\n\n```ts\ninterface LeaderboardDefinition {\n LeaderboardID: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null; // label for the score column, e.g. \"Kills\", \"Time\"\n AssetPaths?: Record<string, string> | null;\n IsEnabled?: boolean | null; // default true; false rejects reads/writes\n\n ScoreAggregation?: string | null; // Sum | BestScore | BestTime | LastValue; default Sum\n Schedule?: ScheduleSpec | null; // schedule/instance container (shared block); default Mode=Cyclic\n ChainEvents?: LeaderboardChainedEvent[] | null; // phases, when Schedule.Mode === \"Chained\"\n Gate?: SegmentGate | null; // who can see/participate (shared block)\n\n TopUsersLimit?: number | null; // max entries stored/returned in the top list; default 100\n MinScoreToEnterTop?: number | null; // score floor to appear in the top list; default 1\n BracketSize?: number | null; // players per bracket; default 0 = one global board\n\n LinkedSeasonChainID?: string | null; // ties rank/milestone rewards to a Season chain's tiers\n\n RankRewards?: LeaderboardRankReward[] | null;\n Milestones?: Record<string, MilestoneDefinition> | null; // key = MilestoneID\n\n MilestoneClaimMode?: string | null; // Instant | AfterEventEnd | FeaturedAfterEnd; default Instant\n MilestoneClaimGraceHours?: number | null; // claim window after cycle/instance end; default 0\n\n ScoreSources?: TriggerSource[] | null; // what `submitScoreFromSource` matches against\n}\n```\n\nA leaderboard with `IsEnabled: false` rejects reads/writes with\n`\"Leaderboard '{id}' is disabled\"` — check it before showing the screen.\n`Gate` is the same `SegmentGate` used across the SDK (segments, premium tier,\nlevel range, country, experiment variant — see `.claude/skills/premium-system/SKILL.md`\nfor how `MinPremiumTier` is evaluated) — all conditions are AND-ed,\nempty/absent gate = open to everyone. A player failing the gate gets\n`\"Leaderboard '{id}' is not available for you\"` from both `getLeaderboard`\nand `submitScore`.\n\n---\n\n## Score aggregation\n\n`ScoreAggregation` controls what `submitScore`'s `NewScore` becomes relative\nto the player's existing score in the current cycle\n(`LeaderboardV2.AggregateScore`):\n\n| Mode | Behavior |\n| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Sum` | Adds the submitted value to the running total. |\n| `BestScore` | Keeps the higher of the two (`Math.Max(current, incoming)`). |\n| `BestTime` | Keeps the lower of the two (`Math.Min`), except the very first submission (`current == 0`) always wins outright — a `0` current score never beats a real time. |\n| `LastValue` | Always overwrites with the latest submission. |\n\nThis math is backend-owned — the SDK never recomputes it; always read\n`NewScore` from `SubmitScoreResponse` rather than predicting it client-side.\n`ScoreEarnedThisCycle` in the cache tracks the sum of raw submissions\nregardless of aggregation mode (useful for milestone progress even under\n`BestScore`/`LastValue`, where `CurrentScore` itself may not move on every\nsubmit). Top-list sort order (`LeaderboardV2.SortTopUsers`) mirrors this:\n`BestTime` sorts ascending by score (lower = better), everything else sorts\ndescending; ties break by whoever reached that score first\n(`LastScoreUpdateUtc` ascending, both for `BestTime` and the other modes).\n\n`ScoreSources`-driven accrual (`submitScoreFromSource`, and all server-side\nboard/marketplace score grants) is accumulative by nature and is therefore\n**only wired up for `Sum`-aggregated leaderboards** — see\n[Score sources](#score-sources-scoresources--submitscorefromsource).\n\n---\n\n## Schedule modes: Cyclic vs Scheduled vs Chained\n\n`Schedule` is the shared `ScheduleSpec` block (same primitive used by\nTimedEvent, TimedBoost, CoopEvent, Season, DealOffer). For leaderboards only\nthree `Mode` values matter in practice:\n\n```ts\ninterface ScheduleSpec {\n Mode?: string; // Cyclic | Scheduled | Chained | AlwaysOn | Triggered | Relative\n IsActive?: boolean;\n Scheduled?: ScheduledWindow; // fixed StartUtc/EndUtc window (+ ClaimGraceHours, AllowEarningAfterEnd)\n Chain?: ScheduleChain; // recurring phase-chain timing (AnchorUtc, PauseBetweenPhasesSec, PauseBetweenCyclesSec)\n Cyclic?: CyclicSchedule; // calendar-aligned reset (Reset: Daily/Weekly/.../Never, + FixedInterval fields)\n ActivationTriggers?: TriggerSource[]; // unused by Leaderboard (Triggered mode not resolved here)\n Relative?: RelativeWindow; // unused by Leaderboard\n}\n```\n\nEach mode gives the leaderboard a different **instance identity** — this is\nthe single most important thing to get right when building UI around\n`CycleIndex`/`ChainedEventID`/grace windows:\n\n- **`Cyclic`** (default) — one leaderboard, an endless series of cycles keyed\n by a calendar-aligned `CyclicSchedule.Reset` (`Daily`/`Weekly`/`Monthly`/...\n /`Never`/`FixedInterval`). The doc ID is fixed\n (`{titleID}_{leaderboardID}`); what moves is `CycleVersion`, incremented\n each reset. `submitScore`, `getMyProgress`, `claimCycleReward`, and\n `claimMilestone` all operate on the _current_ cycle implicitly — you never\n pass a cycle selector for normal play. Past cycles are only reachable via\n `ClaimableGraceCycles` (see [Milestones](#milestones--claim-modes)) and via\n `LeaderboardRequest.CycleKey` on `claimMilestone` — the SDK's\n `claimMilestone(leaderboardID, milestoneID)` does not currently expose a\n `CycleKey` parameter, so client code can only claim grace-window milestones\n the SDK's typed method surfaces for the _current_ call shape; treat this as\n a backend capability documented for completeness rather than something the\n bundled recipes exercise beyond what's already reachable from\n `ClaimableGraceCycles`.\n- **`Scheduled`** — a single fixed window (`Schedule.Scheduled.StartUtc`/\n `EndUtc`). There is exactly one instance, ever, for that config. Before\n `StartUtc` or after `EndUtc + ClaimGraceHours` there is no active instance\n (`getLeaderboard`/`getOverviewBatch` return an empty top list;\n `submitScore` fails with `\"has no active instance right now\"`).\n- **`Chained`** — a repeating sequence of named phases (`ChainEvents`, each\n with `Order`/`DurationSec`/`ClaimGraceHours`), running back-to-back with\n `Schedule.Chain.PauseBetweenPhasesSec` between phases and\n `PauseBetweenCyclesSec` between full loops of the chain. Each\n `(CycleIndex, ChainedEventID)` pair is an **independent leaderboard\n instance** — its own top list, its own `RankRewards`/`Milestones` slate\n (shared config, independent progress), its own bracket set, its own token\n bucket. `LeaderboardRequest` (and hence some backend actions) can address a\n specific past phase via `CycleIndex`/`ChainedEventID`, but the SDK's\n everyday methods (`submitScore`, `getMyProgress`, `claimCycleReward`,\n `claimMilestone`) implicitly target the _current_ active phase; you\n encounter `ChainedEventID`/`CycleIndex` values mainly when reading them\n back off `LeaderboardGraceCycle` for a phase that just ended.\n\nFor all three modes, `CycleVersion` on `SubmitScoreResponse`/\n`GetLeaderboardResponse` is what the SDK cache compares against the cached\n`ScoreCycleVersion` to detect \"this is a new cycle, reset local per-cycle\nstate\" — for `Scheduled`/`Chained` instances this version is always `1`\n(instance identity lives in the doc ID, not the version number).\n\n`ScheduledWindow.ClaimGraceHours` / each `LeaderboardChainedEvent\n.ClaimGraceHours` set how long after that instance ends its rank reward and\nmilestones stay claimable — mirrors the `MilestoneClaimGraceHours` concept\ndescribed in [Milestones](#milestones--claim-modes) but is a _per-instance_\nwindow for Scheduled/Chained rather than the leaderboard-wide\n`MilestoneClaimGraceHours` used by Cyclic.\n\n---\n\n## Rank rewards\n\n```ts\ninterface LeaderboardRankReward {\n Rank?: string; // rank range: \"1\", \"2-5\", \"6-10\", \"11-50\", ...\n Rewards?: ResourceGrant; // grant-only — no cost side\n SeasonTierRewards?: SeasonTierRewardSet; // optional season-tier scaling overlay\n}\n```\n\n`Rank` is a string range matched against the player's final rank when the\ncycle/instance closes (`LeaderboardV2.IsRankInRange`: an exact integer like\n`\"1\"`, or an inclusive `\"start-end\"` range). `claimCycleReward` looks up\nwhich range the player's `Rank` (from `ClaimCycleRewardResponse.Rank`) falls\ninto and pays out that entry's `Rewards`. If `LinkedSeasonChainID` is set,\n`SeasonTierRewards` can scale or replace the base reward based on the\nplayer's tier in that season chain, resolved from a **snapshot**\n(`LeaderboardUserEntry.SeasonTierAtCycleEnd`) taken when the cycle/instance\nwas archived — not the player's current tier — so a season reset between\ncycle-end and claim doesn't change what a past cycle pays out. (`Bundles`\nreplace on match, `Multipliers` scale the base `Rewards.Amounts` — same\noverlay mechanic as `MilestoneDefinition.SeasonTierRewards`.)\n\nA player only has something to claim when `HasUnclaimedReward` (from\n`getMyProgress`) is true, mirrored into the cache as\n`UnclaimedRewardCycleVersion > 0`. If the player wasn't in the final top list,\nor their rank has no matching `RankRewards` entry, or the resolved grant is\nempty after premium filtering, the backend auto-marks the cycle as claimed\nwithout granting anything (`\"No unclaimed reward\"` on a later claim attempt,\nor a normal `claimCycleReward` success with an empty `Resources`) — don't\ntreat either as an error state to alarm the player over.\n\n---\n\n## Milestones & claim modes\n\n`Milestones` uses the SDK-wide `MilestoneDefinition` primitive (shared with\nQuest, TimedEvent, CoopEvent, DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // threshold checked against accumulated progress\n Rewards?: ResourceGrant;\n BonusRewards?: ResourceGrant; // paid during a bonus window, if applicable\n SeasonTierRewards?: SeasonTierRewardSet;\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nFor leaderboards, progress toward a milestone is tracked by a dedicated\n**leaderboard event token** (`EventTokenType.Leaderboard`, address\n`\"{leaderboardID}:{cycleKey}\"` for Cyclic or `\"{leaderboardID}:{instanceKey}\"`\nfor Scheduled/Chained) — its cumulative `Balance.TotalEarned` is the\nscore-earning activity in the current cycle/instance\n(`ScoreEarnedThisCycle` / the response's `MilestoneTokensEarned`), separate\nfrom the ranked `CurrentScore`. A `BestScore` leaderboard where you resubmit\na lower score still accrues milestone progress from the submission, because\nthe token accrues every submitted amount regardless of aggregation.\n\n`MilestoneClaimMode` controls _when_ a reached milestone can be claimed\n(`IDosGames.MilestoneClaimMode`, shared enum — **note the exact member\nnames**, they are not `\"AfterCycleEnd\"`):\n\n- **`Instant`** — claimable as soon as it's reached, any time during the\n cycle/instance, via `claimMilestone` / `claimMilestonesBatch`.\n- **`AfterEventEnd`** — every milestone must wait until the cycle/instance\n closes, and then only within `MilestoneClaimGraceHours` (Cyclic) or the\n instance's own `ClaimGraceHours` (Scheduled/Chained) of the close. Claiming\n early fails with `\"Milestone can only be claimed after cycle ends\"` (Cyclic)\n or `\"...after instance ends\"` (Scheduled/Chained).\n- **`FeaturedAfterEnd`** — hybrid: milestones with `IsFeatured: false` claim\n `Instant`-style any time; milestones with `IsFeatured: true` behave like\n `AfterEventEnd` (`\"Featured milestone can only be claimed after cycle\nends\"`).\n\n`GetMyProgressResponse.ClaimableGraceCycles` lists past cycles/instances\nstill inside the claim window:\n\n```ts\ninterface LeaderboardGraceCycle {\n CycleKey?: string;\n CycleEndUtc?: string;\n ClaimDeadlineUtc?: string; // CycleEndUtc + grace hours\n MilestoneTokensEarned?: number;\n ClaimableMilestoneIDs?: string[]; // milestones from that cycle/instance still claimable\n CycleIndex?: number; // Chained only\n ChainedEventID?: string; // Chained only\n}\n```\n\nOnce `ClaimDeadlineUtc` passes, that cycle/instance's milestones are no\nlonger claimable — check `ClaimableGraceCycles` (and its per-cycle deadline)\nrather than assuming a reached-but-unclaimed milestone stays claimable\nindefinitely. `MilestoneClaimGraceHours: 0` (the default) means Cyclic grace\nis disabled entirely — once a cycle rolls over, its milestones are gone for\ngood.\n\n---\n\n## Brackets\n\nWhen `BracketSize` is set (default `0` = disabled), the leaderboard splits\nplayers into fixed-size brackets rather than one global ranking (keeps ranks\nmeaningful at scale). A player is assigned to a bracket on their **first\nscore submission of the cycle**; resubmissions in the same cycle update their\nscore within that same bracket. A player's current bracket is `BracketID` on\nboth the cached progress and `GetLeaderboardResponse.BracketID`. `BracketID`\nresets to `null` whenever the cache detects a new cycle (see the score-submit\nreset rule above) — a new cycle reshuffles brackets. Bracket assignment\nretries up to 5 times server-side under contention (multiple players racing\nto fill the last slot of a bracket); in the rare case all retries are\nexhausted the player is left without a bracket for that cycle and sees an\nempty top list until their next submission.\n\n---\n\n## Score sources (ScoreSources / submitScoreFromSource)\n\n`ScoreSources: TriggerSource[]` lets a leaderboard accrue score automatically\nfrom game events, instead of relying solely on an explicit `submitScore`\ncall — the same unified `TriggerSource` primitive TimedEvent uses for token\nsources:\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // e.g. \"DailyLogin\", \"ReferralInvite\", \"CustomAction\", board/quest/store/marketplace types\n BaseWeight?: number; // score granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's rollMultiplier/volume\n TileTypeFilter?: string[];\n TileIndexFilter?: number[];\n ChanceOutcomeFilter?: string[];\n OutcomeFilter?: string[];\n Params?: Record<string, string>;\n Limits?: LimitSpec;\n}\n```\n\nTwo independent gates apply, both verified in `LeaderboardV2.cs`:\n\n1. **Aggregation gate** — `ScoreSources` are cumulative deltas\n (`BaseScore × multiplier`), so they're only ever matched/applied on\n `Sum`-aggregated leaderboards. On any other `ScoreAggregation`, both the\n server-side board/marketplace grant builders and\n `submitScoreFromSource` silently skip (no score change, no error) — for\n `BestScore`/`BestTime`/`LastValue` leaderboards, score must come from an\n explicit `submitScore` with the real value (a time, a high score), not\n accumulate off game events.\n2. **Client/server source gate** — `submitScoreFromSource` (the client-facing\n gateway) accepts ONLY `CustomAction` (with your own `Params`) and\n `DailyLogin` (a whitelist since 2026-09-28). Every other type is rejected\n with `\"Source '{type}' cannot be called from client.\"` — the server credits\n them itself when the action happens: board rolls, store purchases, quest\n claims, marketplace deals, new invited friends (`ReferralInvite`), currency\n spent/earned, real-money purchases, lootbox opens, crypto, rank rewards.\n3. **A board fed only by server sources refuses a client score** — a\n `submitScore`/`submitScoresBatch` into it fails with\n `\"Leaderboard '{id}' is scored by the server only.\"` (e.g. a weekly\n \"top inviters\" board). Show it; don't submit to it.\n\nIf no configured `ScoreSources` entry matches the given `sourceType`/\n`outcome`/`params` (or `BaseWeight <= 0`), the call is a **benign no-op**:\n`ok: true` with `{ NewScore: 0, CycleVersion: 0 }`, not an error — the\nbackend deliberately doesn't leak \"does this source exist\" information via\nan error. Don't render a `NewScore` toast without checking it's `> 0`.\n\n---\n\n## Responses\n\n```ts\ninterface LeaderboardUserEntry {\n UserID: string;\n Score?: number;\n Rank?: number;\n LastScoreUpdateUtc?: string;\n IpHash?: string;\n PublicProfile?: UserPublicDataModel; // display name/avatar for rendering the row\n SeasonTierAtCycleEnd?: number; // this entry's season tier when the cycle closed (archive snapshot only; 0 while cycle is active)\n}\n\ninterface GetLeaderboardResponse {\n LeaderboardID: string;\n CycleVersion?: number;\n TotalParticipants?: number;\n CycleEndUtc?: string;\n TopUsers?: LeaderboardUserEntry[]; // length capped at TopUsersLimit\n BracketID?: string; // set when BracketSize splits the board\n}\n\ninterface GetMyProgressResponse {\n LeaderboardID: string;\n CurrentScore?: number;\n LastKnownRank?: number;\n HasUnclaimedReward?: boolean;\n ScoreEarnedThisCycle?: number;\n MilestoneTokensEarned?: number;\n NextMilestone?: MilestoneDefinition; // next unreached milestone, or absent if none left\n HasUnclaimedMilestone?: boolean;\n ClaimableGraceCycles?: LeaderboardGraceCycle[];\n}\n\ninterface SubmitScoreResponse {\n LeaderboardID: string;\n NewScore: number;\n CycleVersion: number;\n}\n\ninterface ClaimCycleRewardResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n Rank?: number;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimLeaderboardMilestoneResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n MilestoneID: string;\n Resources?: ResourceOperation;\n}\n\ninterface LeaderboardOverview {\n LeaderboardID: string;\n Leaderboard?: BatchItemResult<GetLeaderboardResponse> | null;\n Progress?: BatchItemResult<GetMyProgressResponse> | null;\n}\n\ninterface ClaimAllRewardsResult {\n LeaderboardID: string;\n CycleReward?: BatchItemResult<ClaimCycleRewardResponse> | null;\n Milestones?: BatchItemResult<ClaimLeaderboardMilestoneResponse>[] | null;\n}\n```\n\n---\n\n## Batch semantics\n\nAll batch/array-of-IDs inputs (`LeaderboardIDs`, `Milestones` pairs, `Scores`\npairs) go through the same normalization on the backend\n(`BatchSupport.NormalizeIDs` / equivalent inline dedup in\n`SubmitScoresBatch`/`ClaimMilestonesBatch`): entries are trimmed, empty/\ninvalid ones dropped, duplicates removed (first occurrence wins, by ID for\nsingle-value batches or by the natural composite key — `LeaderboardID` for\nscores, `` `${LeaderboardID}:${MilestoneID}` `` for milestone pairs), then the\nlist is **clamped to 50 items**. Anything past the 50th is **silently\ndropped** — it never appears in the result array at all, success or failure.\nThe SDK does not pre-validate, warn, or chunk this for you.\n\n**Atomicity is per-subset, not per-call**, and this is the biggest surprise\nrelative to other v2 modules' batch actions:\n\n- `submitScoresBatch`: leaderboards that are `Cyclic` **and** not bracketed\n (`BracketSize == 0`) are all folded into a **single Mongo transaction** —\n either every one of those items' score+top-list update commits, or none\n do. Leaderboards that are bracketed, or `Scheduled`/`Chained`, are instead\n processed **one at a time** by internally re-calling the same code path as\n the single `submitScore` action — each such item is its own independent\n operation, can fail on its own, and does not affect the atomic group's\n outcome (or vice versa).\n- `claimCycleRewardsBatch` / `claimMilestonesBatch` / `claimAllRewardsBatch`:\n same split. `Cyclic` (non-bracketed or bracketed — bracket archives are\n just a different lookup, still part of the cyclic path) targets are merged\n into **one atomic claim** (`ApplyResourceOperationAtomicAsync` with all\n their patches/filters ANDed and their `Resources` grants merged — see\n `ApplyClaimBatchAsync`/`BatchSupport.ApplyMergedClaimAsync`). `Scheduled`/\n `Chained` targets are each claimed independently (one atomic operation\n _per instance_, since instances have no shared version to gate a joint\n transaction on), and for `claimAllRewardsBatch`/`claimCycleRewardsBatch` a\n non-cyclic leaderboard may internally claim **several ended-but-in-grace\n instances at once**, merging their resources into one aggregated\n `BatchItemResult` entry.\n- Where a merged claim did pay out resources, the combined `ResourceOperation`\n is attached to only the **first** successful item that had a non-empty\n grant in each atomic group (`resourcesAttached` flag) — don't expect every\n successful item in a multi-leaderboard claim to carry its own slice of\n `Resources`; read the aggregate off whichever item has it and trust the\n SDK's applied-balance side effects for the rest.\n- Invalid items (unknown leaderboard/milestone, \"already claimed\", \"no\n unclaimed reward\", timing violations) are filtered out **before** the\n atomic transaction is built, so one stale/bad entry in a batch never blocks\n the others from committing.\n\nIdempotency for single calls follows the platform-wide `RelatedEntityID`\npattern (`ResourceService.ResolveRelatedEntityID`): cycle\nclaims key off `lb_cycle_{leaderboardID}_v{cycleVersion}` (or\n`lb_inst_{instanceDocID}` for Scheduled/Chained), milestone claims key off\n`lb_milestone_{tokenEntityID}_{milestoneID}`. Batch claims build one\ncombined, deterministic reason from the sorted keys of everything actually\nincluded in that transaction (`BatchSupport.BuildBatchReason`) — retries with\nthe same effective item set are idempotent; changing the item set changes the\nreason.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multiplayer-realtime",
|
|
3
|
-
"description": "Build realtime peer-to-peer multiplayer in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.multiplayer (MultiplayerService): create/join/leave/close rooms, list public rooms, relay WebRTC SDP offer/answer + ICE candidates between room members, long-poll for signals/chat/presence envelopes, send server-relayed chat,
|
|
4
|
-
"content": "---\nname: multiplayer-realtime\ndescription: >-\n Build realtime peer-to-peer multiplayer in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.multiplayer\n (MultiplayerService): create/join/leave/close rooms, list public rooms,\n relay WebRTC SDP offer/answer + ICE candidates between room members,\n long-poll for signals/chat/presence envelopes, send server-relayed chat, and\n publish member ready/lobby state. This is a signaling and room-lifecycle\n relay for WebRTC — not a gameplay/economy module. Use this whenever the user\n is working in the iDosGames TS SDK or its game templates and wants realtime\n multiplayer rooms/lobbies, WebRTC signaling, peer-to-peer connection setup,\n a poll loop for realtime events, in-room chat, or ready-check/lobby-state\n UIs, or otherwise touches client.multiplayer, MultiplayerService,\n MultiplayerModels, RoomSnapshotResponse, RealtimeEnvelope, or EnvelopeType —\n even if they don't name the module explicitly.\n---\n\n# Multiplayer realtime signaling (iDosGames TS SDK)\n\nMultiplayer is a **realtime WebRTC signaling relay**, not a gameplay or\neconomy module. It gives you room lifecycle (create/join/leave/list/close) and\na message relay for two things peer-to-peer games need: SDP offer/answer + ICE\ncandidate exchange (so browsers/clients can establish a direct WebRTC\nconnection), and optional server-relayed chat/presence. The actual game state\nsync between players happens over the WebRTC connection you establish\nyourself — this module only gets players into the same room and helps them\nfind each other.\n\n**No currency/item coupling, no per-player cache mirror.** Unlike every other\nmodule in this SDK, no Multiplayer action ever returns a `ResourceOperation`\nor writes to `client.data.user.state` — there is nothing to mirror into a\nlocal player-state cache. Room state itself is ephemeral (Redis, TTL-based, no\nMongo document), so the SDK surface here is pure transport + event-emission:\ncall a method, get a response, emit an event, done. There is no\n`user:multiplayerUpdated` event and no `client.data.user.state.Multiplayer` —\ndon't look for either. The one piece of _config_ this module does expose —\n`MultiplayerDefinitions` (feature switch, topology/chat defaults, room-size\ncap, TTL, payload limits, ICE servers) — rides in on the title's public\nconfig bundle rather than a dedicated fetch call; see\n[Reading limits from config](#reading-limits-from-config).\n\nThis skill is for **using** the production `MultiplayerService`, not for\nporting or extending it, and not for implementing WebRTC itself — you still\nown the `RTCPeerConnection` on each client; this module only relays the\nsignaling payloads between them. Server rejections (room full, wrong join\ncode, not the owner, payload too large) are backend rules — surface them,\ndon't try to pre-validate every one of them client-side.\n\n## Mental model\n\n1. One player **creates a room** (picks topology/chat mode/visibility/max\n players, optionally a join code); others **join** by room id (+ join code\n if private). **A player can only be in one room at a time** — creating or\n joining while already in a live room fails; leave first.\n2. Each member **polls** the room on a cursor (`LastSeq`) to receive a stream\n of envelopes: other members' signals, chat, presence changes\n (join/leave/host-migration), and room-closed notices. Polling is also the\n heartbeat — it's what keeps your presence and the room's Redis TTL alive.\n3. To establish a direct connection, members exchange SDP offer/answer and ICE\n candidates by calling **`sendSignal`** — the server just relays the\n `payload` string to a specific `targetUserID`; it never inspects or\n validates WebRTC semantics.\n4. Optional **`sendChat`** (only meaningful when the room's `ChatMode` is\n `ServerRelay` — under `P2P` chat should go over your own DataChannel\n instead) and **`setMemberState`** (ready flags + a free-form string map)\n round out lobby UX.\n5. Someone **leaves**, or the owner/host **closes** the room; on host\n departure the server may migrate to a `NewHostUserID` if members remain.\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 mp = client.multiplayer; // the MultiplayerService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args — missing room id/name/target/kind), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the transport's throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (the backend rejected it — `error`\ncarries a human-readable reason; see the verbatim strings below).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------- |\n| `createRoom(roomName, options?)` | Create a room; caller becomes owner/host. | `RoomSnapshotResponse` |\n| `joinRoom(roomID, joinCode?)` | Join an existing room. | `RoomSnapshotResponse` |\n| `leaveRoom(roomID)` | Leave a room (may trigger host migration or close it if empty). | `LeaveRoomResponse` |\n| `listRooms(page?, pageSize?)` | Page through public rooms (default `page=0, pageSize=20`, server caps `pageSize` at 50). | `RoomsPageResponse` |\n| `getRoom(roomID)` | Fetch a fresh snapshot of one room. | `RoomSnapshotResponse` |\n| `closeRoom(roomID)` | Owner/host-only: force-close a room. | `LeaveRoomResponse` |\n| `poll(roomID, lastSeq)` | Long-poll cursor: get every envelope with `Seq > lastSeq`. | `PollResponse` |\n| `sendSignal(roomID, targetUserID, signalKind, payload)` | Relay an SDP offer/answer/ICE candidate to one member. | `PublishResponse` |\n| `sendChat(roomID, chatText)` | Server-relayed chat message (only when `ChatMode: ServerRelay`). | `PublishResponse` |\n| `setMemberState(roomID, ready?, memberState?)` | Publish this member's ready flag / free-form lobby state. | `PublishResponse` |\n\n`createRoom`'s `options`: `{ topology?: MultiplayerTopology (\"Mesh\"|\"Host\"),\nchatMode?: MultiplayerChatMode (\"ServerRelay\"|\"P2P\"), maxPlayers?: number,\nvisibility?: RoomVisibility (\"Public\"|\"Private\"), joinCode?: string }`. Any\nfield you omit falls back to the title's configured defaults\n(`MultiplayerDefinitions.DefaultTopology`/`DefaultChatMode`), and an override\nis only honored if the title allows it\n(`AllowCreatorTopologyOverride`/`AllowCreatorChatOverride`) — otherwise the\ncall fails server-side with `\"Topology override is not allowed for this\ntitle.\"` / `\"Chat mode override is not allowed for this title.\"`.\n`maxPlayers` is clamped down to the title's `MaxPlayersPerRoom` hard cap; you\ncan only ask for _fewer_, never more.\n\n`closeRoom` succeeds for either the room's `OwnerUserID` **or** its current\n`HostUserID` (these can diverge after a host migration) — anyone else gets\n`\"Only the room owner or host can close the room.\"`.\n\nEvery method still emits its event on success even though nothing is cached —\nevents are your only hook here, there's no cache to read afterward.\n\n### Server error strings (verbatim)\n\nThese are exact `error` text from the backend (`RoomService.cs` /\n`SignalingService.cs`) — match on substrings if you need to branch, but the\nsafe default is just to display `result.error` as-is:\n\n- `\"Multiplayer is disabled for this title.\"` — title-wide kill switch is off.\n- `\"You are already in room {roomId}. Leave it first.\"` — one-active-room rule,\n on both `createRoom` and `joinRoom`.\n- `\"RoomID is required.\"` / `\"TargetUserID is required.\"` / `\"SignalKind is\nrequired.\"` — same validation the SDK does client-side; you'll only see\n these from a raw/bypassed call.\n- `\"Invalid or missing join code.\"` — private room join code mismatch.\n- `\"Room not found.\"` — unknown room id, or a **private** room you aren't a\n member of (privacy is enforced by returning the same not-found error, not a\n distinct \"forbidden\").\n- `\"Room is closed.\"` / `\"Room is full.\"` — join-time rejections.\n- `\"Only the room owner or host can close the room.\"`\n- `\"Room no longer exists.\"` / `\"You are not a member of this room.\"` — from\n `poll`, `sendSignal`, `sendChat`, `setMemberState` once you've dropped out of\n the member hash (TTL expiry, or someone else removed you).\n- `\"Target user is not in this room.\"` — `sendSignal` to a non-member.\n- `\"Cannot signal yourself.\"`\n- `` `Signal payload exceeds {N} bytes.` `` — `MaxSignalPayloadBytes` (default 16384) exceeded.\n- `` `Chat text exceeds {N} characters.` `` — `MaxChatTextLength` (default 500)\n exceeded.\n- `\"Chat is peer-to-peer in this room; send it over the DataChannel.\"` —\n called `sendChat` while `ChatMode` is `P2P`.\n- `\"Corrupted member record.\"` — internal deserialization failure on\n `setMemberState`; treat as transient/report.\n\n## Reading limits from config\n\nThere's no `getMultiplayerDefinitions()` call — `MultiplayerDefinitions`\ntravels as part of the title's public config bundle, not a per-module\ndefinitions fetch. It's populated by `client.title.getTitlePublicConfiguration()`\n(or already present after login, since the bootstrap `ClientState` includes\nthe title config):\n\n```ts\nimport type { MultiplayerDefinitions } from \"@idosgames/core\";\n\nawait client.title.getTitlePublicConfiguration(); // if not already loaded\nconst cfg = client.data.config.titlePublicConfiguration?.Multiplayer as\n MultiplayerDefinitions | undefined;\n\ncfg?.MaxPlayersPerRoom; // hard cap enforced server-side\ncfg?.MaxChatTextLength;\ncfg?.MaxSignalPayloadBytes;\ncfg?.HeartbeatIntervalMs; // recommended poll cadence, not enforced\ncfg?.MemberTimeoutMs; // client-side presumed-offline threshold\n```\n\nRead it for building config-driven UI (create-room forms that pre-fill/clamp\n`maxPlayers`, a chat box that disables itself when `ChatMode` will be `P2P`,\netc.) — the server enforces the real limits regardless of what you show.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn. There is no\ncoarse `user:*Updated`/`user:anyUpdated` companion for any of these — they\ndon't touch the shared cache.\n\n- `multiplayer:roomCreated` → `RoomSnapshotResponse`\n- `multiplayer:roomJoined` → `RoomSnapshotResponse`\n- `multiplayer:roomLeft` → `LeaveRoomResponse`\n- `multiplayer:roomsListed` → `RoomsPageResponse`\n- `multiplayer:roomLoaded` → `RoomSnapshotResponse`\n- `multiplayer:roomClosed` → `LeaveRoomResponse`\n- `multiplayer:polled` → `PollResponse`\n- `multiplayer:signalSent` → `PublishResponse`\n- `multiplayer:chatSent` → `PublishResponse`\n- `multiplayer:memberStateSet` → `PublishResponse`\n\n```ts\nconst off = client.on(\"multiplayer:roomClosed\", (r) => {\n if (r.Closed) navigateToLobbyList();\n});\n// later: off();\n```\n\nFor the full shape of `RoomSnapshotResponse`, `RealtimeEnvelope`,\n`RoomMemberView`, and `MultiplayerDefinitions`, see\n[references/data-model.md](references/data-model.md).\n\n## Recipes\n\n### Create or join a room\n\n```ts\nconst created = await client.multiplayer.createRoom(\"Match 42\", {\n topology: \"Mesh\",\n chatMode: \"ServerRelay\",\n maxPlayers: 4,\n visibility: \"Private\",\n joinCode: \"AB12\",\n});\nif (!created.ok) return showError(created.error);\nconst { RoomID, Seq, IceServers, Members } = created.data;\n\n// another player:\nconst joined = await client.multiplayer.joinRoom(RoomID, \"AB12\");\nif (!joined.ok) return showError(joined.error); // e.g. wrong code, room full\n```\n\n`IceServers` on the snapshot is the STUN/TURN config for your `RTCPeerConnection`\n— pass it straight through (if the title has none configured, the backend\nfalls back to public Google STUN servers, so this array is never empty).\n`Seq` is your starting poll cursor. Remember the one-active-room rule: a\nsecond `createRoom`/`joinRoom` call while already in a room fails with\n`\"You are already in room {roomId}. Leave it first.\"` — call `leaveRoom` (or\ncheck your own app state) before switching rooms.\n\n### The polling loop (signals, chat, presence)\n\nPoll is a cursor-based long-poll: pass the last `Seq` you've seen, get back\neverything newer, and each response's `MaxSeq` becomes your next cursor. Each\npoll call also renews your presence heartbeat and the room's TTL server-side\n— an idle client that stops polling will eventually look disconnected to\nothers and the room itself can expire.\n\n```ts\nlet lastSeq = created.data.Seq ?? 0;\nlet polling = true;\n\nasync function pollLoop(roomID: string) {\n while (polling) {\n const res = await client.multiplayer.poll(roomID, lastSeq);\n if (!res.ok) {\n if (res.reason === \"connection\") continue; // transient, retry\n break; // unauthorized/server error (\"Room no longer exists.\", etc) — stop and surface it\n }\n for (const env of res.data.Envelopes ?? []) {\n switch (env.Type) {\n case \"Signal\":\n // env.Kind: \"offer\" | \"answer\" | \"candidate\"; env.FromUserID, env.Payload\n handleSignal(env.FromUserID, env.Kind, env.Payload);\n break;\n case \"Chat\":\n appendChatMessage(env.FromUserID, env.Payload);\n break;\n case \"PlayerJoined\":\n case \"PlayerLeft\":\n case \"MemberState\":\n refreshRoomMembers(roomID);\n break;\n case \"HostChanged\":\n break; // env.Payload / a follow-up getRoom() carries new host\n case \"RoomClosed\":\n polling = false;\n break;\n }\n }\n lastSeq = res.data.MaxSeq ?? lastSeq;\n }\n}\n```\n\nThere's no push/websocket transport here — polling cadence is your call. The\ntitle's `MultiplayerDefinitions.HeartbeatIntervalMs` (default 1000ms) is a\n_recommended_ interval only, not enforced — poll too fast and you risk\n`reason: \"throttled\"` at the transport layer; too slow and signaling feels\nlaggy, and other members' `LastSeenMs` will look stale to you. Use the\nrecommended interval as your default; back off on `\"connection\"` errors\nrather than hot-looping.\n\n### Exchange a WebRTC offer/answer\n\n```ts\n// caller side, after creating an RTCPeerConnection and an offer:\nconst offer = await pc.createOffer();\nawait pc.setLocalDescription(offer);\nawait client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"offer\",\n JSON.stringify(offer),\n);\n\n// callee side, from the poll loop's \"Signal\"/\"offer\" case:\nconst offer = JSON.parse(env.Payload);\nawait pc.setRemoteDescription(offer);\nconst answer = await pc.createAnswer();\nawait pc.setLocalDescription(answer);\nawait client.multiplayer.sendSignal(\n roomID,\n env.FromUserID,\n \"answer\",\n JSON.stringify(answer),\n);\n\n// both sides, as ICE candidates trickle in:\npc.onicecandidate = (e) => {\n if (e.candidate) {\n client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"candidate\",\n JSON.stringify(e.candidate),\n );\n }\n};\n```\n\n`signalKind` is a free-form string (`\"offer\"`/`\"answer\"`/`\"candidate\"` by\nconvention) and `payload` is an opaque string — the server relays both without\ninspecting them, so encoding is entirely up to you (JSON-stringify is the\nobvious choice). Keep the serialized payload under the title's\n`MaxSignalPayloadBytes` (default 16384) — an oversized payload is rejected\nwith `` `Signal payload exceeds {N} bytes.` `` before it's ever relayed, and\nyou can't signal yourself (`\"Cannot signal yourself.\"`) or a user who isn't\ncurrently a room member (`\"Target user is not in this room.\"`).\n\n### Lobby ready-check and leaving\n\n```ts\nawait client.multiplayer.setMemberState(roomID, true, { loadout: \"red\" });\n// other members see this via a \"MemberState\" envelope on their next poll,\n// then re-fetch with getRoom(roomID) to read updated Members[].Ready/State.\n\nconst left = await client.multiplayer.leaveRoom(roomID);\nif (left.ok && left.data.Closed) {\n // room was empty/owner left with no migration target — it's gone\n} else if (left.ok && left.data.NewHostUserID) {\n // host migrated; that member is now authoritative for Host topology\n}\n```\n\n## Gotchas\n\n- **Nothing here touches `client.data.user.state`.** Don't look for a cached\n room state or resource balances changing — read everything from method\n responses and poll envelopes, and keep your own local room/member state in\n your app layer. The one thing that _is_ cached is the title-wide\n `MultiplayerDefinitions` config, and only after you fetch the title config\n (see [Reading limits from config](#reading-limits-from-config)).\n- **One room per player, enforced server-side.** `createRoom` and `joinRoom`\n both reject with `\"You are already in room {roomId}. Leave it first.\"` if\n you're already a live member elsewhere — there's no implicit \"leave old,\n join new.\"\n- **Room state is ephemeral.** No persistent document backs a room — it's\n Redis, TTL-based (`RoomTtlSeconds`, default 3600s, renewed on every\n interaction including `poll`). A room can disappear if nobody interacts\n with it; don't assume a `RoomID` is valid indefinitely.\n- **`sendChat` only works under `ChatMode: ServerRelay`.** Under `P2P` chat\n mode, send chat over your own WebRTC DataChannel instead — calling\n `sendChat` in `P2P` mode fails with `\"Chat is peer-to-peer in this room;\nsend it over the DataChannel.\"` (check the room snapshot's `ChatMode`\n before wiring a chat send button to it).\n- **`closeRoom` works for the owner or the current host, not just the\n original creator.** After a host migration the new host can also close the\n room; anyone else gets `\"Only the room owner or host can close the room.\"`.\n Regular non-host, non-owner members only have `leaveRoom` (which itself may\n close the room if it empties out).\n- **Private rooms 404 instead of 403.** `getRoom`/`poll`/etc. on a private\n room you're not a member of returns the same `\"Room not found.\"` as a\n genuinely unknown id — the backend doesn't leak room existence to\n non-members.\n- **No server-side member timeout/eviction.** `RoomMemberView.LastSeenMs` is a\n last-heartbeat timestamp for clients to compare against\n `MultiplayerDefinitions.MemberTimeoutMs` themselves (e.g. to gray out a\n presumed-disconnected member) — the server does not auto-evict stale\n members; your poll loop or UI must apply that timeout logic.\n- **Poll cadence is uncapped by the SDK — you own the rate.** There's no\n built-in backoff or interval; hitting the same endpoint faster than the\n transport's throttle window returns `reason: \"throttled\"` rather than\n duplicating work. `HeartbeatIntervalMs` is the title's _recommended_\n interval, not an enforced one. Design your loop's interval deliberately\n rather than polling in a tight `while(true)`.\n- **`Payload`/`signalKind` are opaque strings.** The server does not validate\n WebRTC SDP/ICE structure — malformed payloads only fail on the _receiving_\n client when it tries to parse/apply them, not at the relay. It does,\n however, enforce a byte-size ceiling (`MaxSignalPayloadBytes`) before\n relaying at all.\n- **Guard against double-submit** on room actions (create/join/close): each\n call is a distinct request, so two separate calls are two real operations\n server-side (e.g. two `createRoom` calls can both succeed as two different\n rooms if the first response is slow). Disable the control while a call is\n in flight.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full shape of\n`RoomSnapshotResponse`, `RoomMemberView`, `RealtimeEnvelope`/`EnvelopeType`,\n`MultiplayerDefinitions`, and the request/action surface, plus the room\nlifecycle state machine (Open → Closed, host migration, TTL expiry). Read it\nwhen building richer room/lobby UI or wiring up the poll loop's envelope\nhandling in detail.\n",
|
|
3
|
+
"description": "Build realtime peer-to-peer multiplayer in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.multiplayer (MultiplayerService): create/join/leave/close rooms, list public rooms, relay WebRTC SDP offer/answer + ICE candidates between room members, long-poll for signals/chat/presence envelopes, send server-relayed chat, publish member ready/lobby state, take over a silent host (claimHost), kick/ban members; plus the ready-made WebRTC transport RealtimeSession from @idosgames/core/realtime (DataChannels, adaptive polling, host handover, TURN from the room snapshot). This is a signaling and room-lifecycle relay for WebRTC — not a gameplay/economy module. Use this whenever the user is working in the iDosGames TS SDK or its game templates and wants realtime multiplayer rooms/lobbies, WebRTC signaling, peer-to-peer connection setup, a poll loop for realtime events, in-room chat, or ready-check/lobby-state UIs, or otherwise touches client.multiplayer, MultiplayerService, MultiplayerModels, RoomSnapshotResponse, RealtimeEnvelope, EnvelopeType or RealtimeSession — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: multiplayer-realtime\ndescription: >-\n Build realtime peer-to-peer multiplayer in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.multiplayer\n (MultiplayerService): create/join/leave/close rooms, list public rooms,\n relay WebRTC SDP offer/answer + ICE candidates between room members,\n long-poll for signals/chat/presence envelopes, send server-relayed chat,\n publish member ready/lobby state, take over a silent host (claimHost),\n kick/ban members; plus the ready-made WebRTC transport\n RealtimeSession from @idosgames/core/realtime (DataChannels, adaptive\n polling, host handover, TURN from the room snapshot). This is a signaling\n and room-lifecycle relay for WebRTC — not a gameplay/economy module. Use this whenever the user\n is working in the iDosGames TS SDK or its game templates and wants realtime\n multiplayer rooms/lobbies, WebRTC signaling, peer-to-peer connection setup,\n a poll loop for realtime events, in-room chat, or ready-check/lobby-state\n UIs, or otherwise touches client.multiplayer, MultiplayerService,\n MultiplayerModels, RoomSnapshotResponse, RealtimeEnvelope, EnvelopeType or\n RealtimeSession — even if they don't name the module explicitly.\n---\n\n# Multiplayer realtime signaling (iDosGames TS SDK)\n\nMultiplayer is a **realtime WebRTC signaling relay**, not a gameplay or\neconomy module. It gives you room lifecycle (create/join/leave/list/close) and\na message relay for two things peer-to-peer games need: SDP offer/answer + ICE\ncandidate exchange (so browsers/clients can establish a direct WebRTC\nconnection), and optional server-relayed chat/presence. The actual game state\nsync between players happens over the WebRTC connection you establish\nyourself — this module only gets players into the same room and helps them\nfind each other.\n\n**No currency/item coupling, no per-player cache mirror.** Unlike every other\nmodule in this SDK, no Multiplayer action ever returns a `ResourceOperation`\nor writes to `client.data.user.state` — there is nothing to mirror into a\nlocal player-state cache. Room state itself is ephemeral (Redis, TTL-based, no\nMongo document), so the SDK surface here is pure transport + event-emission:\ncall a method, get a response, emit an event, done. There is no\n`user:multiplayerUpdated` event and no `client.data.user.state.Multiplayer` —\ndon't look for either. The one piece of _config_ this module does expose —\n`MultiplayerDefinitions` (feature switch, topology/chat defaults, room-size\ncap, TTL, payload limits, ICE servers) — rides in on the title's public\nconfig bundle rather than a dedicated fetch call; see\n[Reading limits from config](#reading-limits-from-config).\n\nThis skill is for **using** the production `MultiplayerService`, not for\nporting or extending it. **Don't hand-roll WebRTC** on top of it: the SDK ships\n`RealtimeSession` (`@idosgames/core/realtime`, see\n[Recommended: RealtimeSession](#recommended-realtimesession)) which owns the\n`RTCPeerConnection`s, the poll loop and the signaling etiquette the backend\nexpects. The raw methods below are for lobby UI and for the rare custom\ntransport. Server rejections (room full, wrong join\ncode, not the owner, payload too large) are backend rules — surface them,\ndon't try to pre-validate every one of them client-side.\n\n## Mental model\n\n1. One player **creates a room** (picks topology/chat mode/visibility/max\n players, optionally a join code); others **join** by room id (+ join code\n if private). **A player can only be in one room at a time** — creating or\n joining while already in a live room fails; leave first.\n2. Each member **polls** the room on a cursor (`LastSeq`) to receive a stream\n of envelopes: other members' signals, chat, presence changes\n (join/leave/host-migration), and room-closed notices. Polling is also the\n heartbeat — it's what keeps your presence and the room's Redis TTL alive.\n3. To establish a direct connection, members exchange SDP offer/answer and ICE\n candidates by calling **`sendSignal`** — the server just relays the\n `payload` string to a specific `targetUserID`; it never inspects or\n validates WebRTC semantics.\n4. Optional **`sendChat`** (only meaningful when the room's `ChatMode` is\n `ServerRelay` — under `P2P` chat should go over your own DataChannel\n instead) and **`setMemberState`** (ready flags + a free-form string map)\n round out lobby UX.\n5. Someone **leaves**, or the owner/host **closes** the room; on host\n departure the server may migrate to a `NewHostUserID` if members remain.\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 mp = client.multiplayer; // the MultiplayerService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args — missing room id/name/target/kind), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the transport's throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (the backend rejected it — `error`\ncarries a human-readable reason; see the verbatim strings below).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |\n| `createRoom(roomName, options?)` | Create a room; caller becomes owner/host. | `RoomSnapshotResponse` |\n| `joinRoom(roomID, joinCode?)` | Join an existing room. | `RoomSnapshotResponse` |\n| `leaveRoom(roomID, { successorUserID?, keepalive? }?)` | Leave a room (may trigger host migration or close it if empty). A leaving host may name the successor; `keepalive` lets the request outlive the page (`pagehide`). | `LeaveRoomResponse` |\n| `listRooms(page?, pageSize?)` | Page through public rooms (default `page=0, pageSize=20`, server caps `pageSize` at 50). | `RoomsPageResponse` |\n| `getRoom(roomID)` | Fetch a fresh snapshot of one room. | `RoomSnapshotResponse` |\n| `closeRoom(roomID)` | Owner/host-only: force-close a room. | `LeaveRoomResponse` |\n| `poll(roomID, lastSeq)` | Long-poll cursor: get every envelope with `Seq > lastSeq`. | `PollResponse` |\n| `sendSignal(roomID, targetUserID, signalKind, payload)` | Relay an SDP offer/answer/ICE candidate to one member. | `PublishResponse` |\n| `sendChat(roomID, chatText)` | Server-relayed chat message (only when `ChatMode: ServerRelay`). | `PublishResponse` |\n| `setMemberState(roomID, ready?, memberState?)` | Publish this member's ready flag / free-form lobby state. | `PublishResponse` |\n| `claimHost(roomID)` | Take the host role from a host that stopped polling (silent longer than `MemberTimeoutMs`). The silent host is evicted; `PlayerLeft` (Kind `\"timeout\"`) + `HostChanged` go to everyone. Exactly one claimant wins. | `RoomSnapshotResponse` |\n| `kickMember(roomID, targetUserID, ban?)` | Owner/host-only: remove a member (`PlayerLeft`, Kind `\"kicked\"`); `ban` also puts them on the room's ban list (survives host changes, dies with the room). Their TURN credentials are revoked. | `KickMemberResponse` (`Removed`, `Banned`) |\n| `unbanMember(roomID, targetUserID)` | Owner/host-only: take a player off the ban list. | `UnbanMemberResponse` (`WasBanned`) |\n\n`createRoom`'s `options`: `{ topology?: MultiplayerTopology (\"Mesh\"|\"Host\"),\nchatMode?: MultiplayerChatMode (\"ServerRelay\"|\"P2P\"), maxPlayers?: number,\nvisibility?: RoomVisibility (\"Public\"|\"Private\"), joinCode?: string }`. Any\nfield you omit falls back to the title's configured defaults\n(`MultiplayerDefinitions.DefaultTopology`/`DefaultChatMode`), and an override\nis only honored if the title allows it\n(`AllowCreatorTopologyOverride`/`AllowCreatorChatOverride`) — otherwise the\ncall fails server-side with `\"Topology override is not allowed for this\ntitle.\"` / `\"Chat mode override is not allowed for this title.\"`.\n`maxPlayers` is clamped down to the title's `MaxPlayersPerRoom` hard cap; you\ncan only ask for _fewer_, never more.\n\n`closeRoom` succeeds for either the room's `OwnerUserID` **or** its current\n`HostUserID` (these can diverge after a host migration) — anyone else gets\n`\"Only the room owner or host can close the room.\"`.\n\nEvery method still emits its event on success even though nothing is cached —\nevents are your only hook here, there's no cache to read afterward.\n\n### Server error strings (verbatim)\n\nThese are exact `error` text from the backend (`RoomService.cs` /\n`SignalingService.cs`) — match on substrings if you need to branch, but the\nsafe default is just to display `result.error` as-is:\n\n- `\"Multiplayer is disabled for this title.\"` — title-wide kill switch is off.\n- `\"You are already in room {roomId}. Leave it first.\"` — one-active-room rule,\n on both `createRoom` and `joinRoom`.\n- `\"RoomID is required.\"` / `\"TargetUserID is required.\"` / `\"SignalKind is\nrequired.\"` — same validation the SDK does client-side; you'll only see\n these from a raw/bypassed call.\n- `\"Invalid or missing join code.\"` — private room join code mismatch.\n- `\"Room not found.\"` — unknown room id, or a **private** room you aren't a\n member of (privacy is enforced by returning the same not-found error, not a\n distinct \"forbidden\").\n- `\"Room is closed.\"` / `\"Room is full.\"` — join-time rejections.\n- `\"Only the room owner or host can close the room.\"`\n- `\"You were removed from this room.\"` — `joinRoom` by a banned player.\n- `\"The host is still active.\"` — `claimHost` while the host still polls\n (retry later; only a host silent for `MemberTimeoutMs` can be replaced).\n- `\"Only the room owner or host can remove players.\"` / `\"Cannot kick\nyourself.\"` / `\"Cannot remove the host.\"` / `\"Cannot remove the room owner.\"`\n — `kickMember` / `unbanMember`.\n- `\"Room no longer exists.\"` / `\"You are not a member of this room.\"` — from\n `poll`, `sendSignal`, `sendChat`, `setMemberState` once you've dropped out of\n the member hash (TTL expiry, or someone else removed you).\n- `\"Target user is not in this room.\"` — `sendSignal` to a non-member.\n- `\"Cannot signal yourself.\"`\n- `` `Signal payload exceeds {N} bytes.` `` — `MaxSignalPayloadBytes` (default 16384) exceeded.\n- `` `Chat text exceeds {N} characters.` `` — `MaxChatTextLength` (default 500)\n exceeded.\n- `\"Chat is peer-to-peer in this room; send it over the DataChannel.\"` —\n called `sendChat` while `ChatMode` is `P2P`.\n- `\"Corrupted member record.\"` — internal deserialization failure on\n `setMemberState`; treat as transient/report.\n\n## Reading limits from config\n\nThere's no `getMultiplayerDefinitions()` call — `MultiplayerDefinitions`\ntravels as part of the title's public config bundle, not a per-module\ndefinitions fetch. It's populated by `client.title.getTitlePublicConfiguration()`\n(or already present after login, since the bootstrap `ClientState` includes\nthe title config):\n\n```ts\nimport type { MultiplayerDefinitions } from \"@idosgames/core\";\n\nawait client.title.getTitlePublicConfiguration(); // if not already loaded\nconst cfg = client.data.config.titlePublicConfiguration?.Multiplayer as\n MultiplayerDefinitions | undefined;\n\ncfg?.MaxPlayersPerRoom; // hard cap enforced server-side\ncfg?.MaxChatTextLength;\ncfg?.MaxSignalPayloadBytes;\ncfg?.HeartbeatIntervalMs; // recommended poll cadence, not enforced\ncfg?.MemberTimeoutMs; // client-side presumed-offline threshold\n```\n\nRead it for building config-driven UI (create-room forms that pre-fill/clamp\n`maxPlayers`, a chat box that disables itself when `ChatMode` will be `P2P`,\netc.) — the server enforces the real limits regardless of what you show.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn. There is no\ncoarse `user:*Updated`/`user:anyUpdated` companion for any of these — they\ndon't touch the shared cache.\n\n- `multiplayer:roomCreated` → `RoomSnapshotResponse`\n- `multiplayer:roomJoined` → `RoomSnapshotResponse`\n- `multiplayer:roomLeft` → `LeaveRoomResponse`\n- `multiplayer:roomsListed` → `RoomsPageResponse`\n- `multiplayer:roomLoaded` → `RoomSnapshotResponse`\n- `multiplayer:roomClosed` → `LeaveRoomResponse`\n- `multiplayer:polled` → `PollResponse`\n- `multiplayer:signalSent` → `PublishResponse`\n- `multiplayer:chatSent` → `PublishResponse`\n- `multiplayer:memberStateSet` → `PublishResponse`\n- `multiplayer:hostClaimed` → `RoomSnapshotResponse`\n- `multiplayer:memberKicked` → `KickMemberResponse`\n- `multiplayer:memberUnbanned` → `UnbanMemberResponse`\n\n```ts\nconst off = client.on(\"multiplayer:roomClosed\", (r) => {\n if (r.Closed) navigateToLobbyList();\n});\n// later: off();\n```\n\nFor the full shape of `RoomSnapshotResponse`, `RealtimeEnvelope`,\n`RoomMemberView`, and `MultiplayerDefinitions`, see\n[references/data-model.md](references/data-model.md).\n\n## Recommended: RealtimeSession\n\n`@idosgames/core/realtime` is the WebRTC transport on top of this module. Use it\ninstead of writing the offer/answer/ICE dance yourself:\n\n```ts\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\n\nconst res = await RealtimeSession.create(client, \"My world\", {\n topology: \"Host\", // star: guests link to the host only\n visibility: \"Public\",\n maxPlayers: 8,\n});\n// guests: await RealtimeSession.join(client, roomID, joinCode?)\nif (!res.ok) return showError(res.error);\nconst session = res.data;\n\nsession.on(\"peerOpen\", ({ peerId }) => sendWorldSnapshot(peerId));\nsession.on(\"message\", ({ from, channel, data }) => handle(from, channel, data));\nsession.on(\"hostChanged\", ({ hostId }) => {\n if (hostId === session.selfId) becomeHost();\n});\nsession.on(\"removed\", ({ reason }) => backToMenu(reason)); // kicked / banned\nsession.on(\"roomClosed\", () => backToMenu(\"closed\"));\n\nsession.send(peerId, \"rel\", bytes); // ordered + reliable, fragmented into 16 KB frames\nsession.broadcast(\"fast\", poseBytes); // unordered, no retransmits: positions etc.\n```\n\nWhat it does for you:\n\n- **Two DataChannels per link**: `rel` (ordered, reliable; large messages are\n fragmented) and `fast` (unordered, `maxRetransmits: 0`). `send` returns\n `false` when the link is not open, a `fast` message is too big, or `rel` is\n backed up — drop or retry, don't queue forever.\n- **Cheap polling**: 1 s only while something is being set up, then the host\n polls every 2.5 s (to see newcomers) and everyone else every 20 s (just to\n stay a member). Signals go through a single-flight, per-peer batched outbox,\n and descriptions carry their ICE candidates — about one `SendSignal` per\n side per connection.\n- **Liveness**: own keepalive on `fast` (a silent peer is closed after ~6 s),\n RTT via `linkStats(peerId)` (measured every ~5 s even under load),\n reconnect with back-off.\n- **Host handover**: the host calls `setSuccessor(userId)`; `leave()` (and\n page close — a `keepalive` request on `pagehide`) passes it to the server.\n If the host tab dies instead, the successor calls `session.claimHost()`\n once the host has been silent for the title's `MemberTimeoutMs`.\n `setExtraPeers([...])` keeps standby links (e.g. every guest to the\n successor) so the handover does not wait for signaling.\n- **Moderation**: `session.kick(userId, ban?)`, `session.unban(userId)`. After\n a successful kick the host sends the kicked player a \"check your membership\"\n notice over the link; that player polls at once and gets `removed` within\n about a round trip. The notice is only a hint — the SERVER confirms the\n removal, so a forged notice costs one extra poll and nothing else.\n- **TURN**: `IceServers` of the snapshot are used as is. When the engine has\n Cloudflare TURN configured (env `CLOUDFLARE_TURN_KEY_ID` / `CLOUDFLARE_TURN_API_TOKEN` on the\n engine — not title config) members get short-lived TURN credentials in the\n snapshot; otherwise STUN only.\n\nA full game built on it: `modules/voxelcraft/src/net/` (NetGame — dimension\nowners, replicas, host migration with a shadow copy of the world).\n\n## Recipes\n\n### Create or join a room\n\n```ts\nconst created = await client.multiplayer.createRoom(\"Match 42\", {\n topology: \"Mesh\",\n chatMode: \"ServerRelay\",\n maxPlayers: 4,\n visibility: \"Private\",\n joinCode: \"AB12\",\n});\nif (!created.ok) return showError(created.error);\nconst { RoomID, Seq, IceServers, Members } = created.data;\n\n// another player:\nconst joined = await client.multiplayer.joinRoom(RoomID, \"AB12\");\nif (!joined.ok) return showError(joined.error); // e.g. wrong code, room full\n```\n\n`IceServers` on the snapshot is the STUN/TURN config for your `RTCPeerConnection`\n— pass it straight through (if the title has none configured, the backend\nfalls back to public STUN servers, so this array is never empty). TURN\ncredentials, when the engine has Cloudflare TURN configured, are included only\nfor room members and expire after ~12 h — never put a static TURN login into\nthe title's public config.\n`Seq` is your starting poll cursor. Remember the one-active-room rule: a\nsecond `createRoom`/`joinRoom` call while already in a room fails with\n`\"You are already in room {roomId}. Leave it first.\"` — call `leaveRoom` (or\ncheck your own app state) before switching rooms.\n\n### The polling loop (signals, chat, presence)\n\nPoll is a cursor-based long-poll: pass the last `Seq` you've seen, get back\neverything newer, and each response's `MaxSeq` becomes your next cursor. Each\npoll call also renews your presence heartbeat and the room's TTL server-side\n— an idle client that stops polling will eventually look disconnected to\nothers and the room itself can expire.\n\n```ts\nlet lastSeq = created.data.Seq ?? 0;\nlet polling = true;\n\nasync function pollLoop(roomID: string) {\n while (polling) {\n const res = await client.multiplayer.poll(roomID, lastSeq);\n if (!res.ok) {\n if (res.reason === \"connection\") continue; // transient, retry\n break; // unauthorized/server error (\"Room no longer exists.\", etc) — stop and surface it\n }\n for (const env of res.data.Envelopes ?? []) {\n switch (env.Type) {\n case \"Signal\":\n // env.Kind: \"offer\" | \"answer\" | \"candidate\"; env.FromUserID, env.Payload\n handleSignal(env.FromUserID, env.Kind, env.Payload);\n break;\n case \"Chat\":\n appendChatMessage(env.FromUserID, env.Payload);\n break;\n case \"PlayerJoined\":\n case \"PlayerLeft\":\n case \"MemberState\":\n refreshRoomMembers(roomID);\n break;\n case \"HostChanged\":\n break; // env.Payload / a follow-up getRoom() carries new host\n case \"RoomClosed\":\n polling = false;\n break;\n }\n }\n lastSeq = res.data.MaxSeq ?? lastSeq;\n }\n}\n```\n\nThere's no push/websocket transport here — polling cadence is your call. The\ntitle's `MultiplayerDefinitions.HeartbeatIntervalMs` (default 1000ms) is a\n_recommended_ interval only, not enforced — poll too fast and you risk\n`reason: \"throttled\"` at the transport layer; too slow and signaling feels\nlaggy, and other members' `LastSeenMs` will look stale to you. Use the\nrecommended interval as your default; back off on `\"connection\"` errors\nrather than hot-looping.\n\n### Exchange a WebRTC offer/answer\n\n```ts\n// caller side, after creating an RTCPeerConnection and an offer:\nconst offer = await pc.createOffer();\nawait pc.setLocalDescription(offer);\nawait client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"offer\",\n JSON.stringify(offer),\n);\n\n// callee side, from the poll loop's \"Signal\"/\"offer\" case:\nconst offer = JSON.parse(env.Payload);\nawait pc.setRemoteDescription(offer);\nconst answer = await pc.createAnswer();\nawait pc.setLocalDescription(answer);\nawait client.multiplayer.sendSignal(\n roomID,\n env.FromUserID,\n \"answer\",\n JSON.stringify(answer),\n);\n\n// both sides, as ICE candidates trickle in:\npc.onicecandidate = (e) => {\n if (e.candidate) {\n client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"candidate\",\n JSON.stringify(e.candidate),\n );\n }\n};\n```\n\n`signalKind` is a free-form string (`\"offer\"`/`\"answer\"`/`\"candidate\"` by\nconvention) and `payload` is an opaque string — the server relays both without\ninspecting them, so encoding is entirely up to you (JSON-stringify is the\nobvious choice). Keep the serialized payload under the title's\n`MaxSignalPayloadBytes` (default 16384) — an oversized payload is rejected\nwith `` `Signal payload exceeds {N} bytes.` `` before it's ever relayed, and\nyou can't signal yourself (`\"Cannot signal yourself.\"`) or a user who isn't\ncurrently a room member (`\"Target user is not in this room.\"`).\n\n### Lobby ready-check and leaving\n\n```ts\nawait client.multiplayer.setMemberState(roomID, true, { loadout: \"red\" });\n// other members see this via a \"MemberState\" envelope on their next poll,\n// then re-fetch with getRoom(roomID) to read updated Members[].Ready/State.\n\nconst left = await client.multiplayer.leaveRoom(roomID);\nif (left.ok && left.data.Closed) {\n // room was empty/owner left with no migration target — it's gone\n} else if (left.ok && left.data.NewHostUserID) {\n // host migrated; that member is now authoritative for Host topology\n}\n```\n\n## Gotchas\n\n- **Nothing here touches `client.data.user.state`.** Don't look for a cached\n room state or resource balances changing — read everything from method\n responses and poll envelopes, and keep your own local room/member state in\n your app layer. The one thing that _is_ cached is the title-wide\n `MultiplayerDefinitions` config, and only after you fetch the title config\n (see [Reading limits from config](#reading-limits-from-config)).\n- **One room per player, enforced server-side.** `createRoom` and `joinRoom`\n both reject with `\"You are already in room {roomId}. Leave it first.\"` if\n you're already a live member elsewhere — there's no implicit \"leave old,\n join new.\"\n- **Room state is ephemeral.** No persistent document backs a room — it's\n Redis, TTL-based (`RoomTtlSeconds`, default 3600s, renewed on every\n interaction including `poll`). A room can disappear if nobody interacts\n with it; don't assume a `RoomID` is valid indefinitely.\n- **`sendChat` only works under `ChatMode: ServerRelay`.** Under `P2P` chat\n mode, send chat over your own WebRTC DataChannel instead — calling\n `sendChat` in `P2P` mode fails with `\"Chat is peer-to-peer in this room;\nsend it over the DataChannel.\"` (check the room snapshot's `ChatMode`\n before wiring a chat send button to it).\n- **`closeRoom` works for the owner or the current host, not just the\n original creator.** After a host migration the new host can also close the\n room; anyone else gets `\"Only the room owner or host can close the room.\"`.\n Regular non-host, non-owner members only have `leaveRoom` (which itself may\n close the room if it empties out).\n- **Private rooms 404 instead of 403.** `getRoom`/`poll`/etc. on a private\n room you're not a member of returns the same `\"Room not found.\"` as a\n genuinely unknown id — the backend doesn't leak room existence to\n non-members.\n- **No automatic eviction — except via `claimHost`.** `RoomMemberView.LastSeenMs`\n is a last-heartbeat timestamp for clients to compare against\n `MultiplayerDefinitions.MemberTimeoutMs` themselves (e.g. to gray out a\n presumed-disconnected member). The server never evicts a stale member on\n its own; the one exception is `claimHost`, which removes a HOST that has\n been silent longer than `MemberTimeoutMs` and hands the role to the caller.\n- **Poll cadence is uncapped by the SDK — you own the rate.** There's no\n built-in backoff or interval; hitting the same endpoint faster than the\n transport's throttle window returns `reason: \"throttled\"` rather than\n duplicating work. `HeartbeatIntervalMs` is the title's _recommended_\n interval, not an enforced one. Design your loop's interval deliberately\n rather than polling in a tight `while(true)`.\n- **`Payload`/`signalKind` are opaque strings.** The server does not validate\n WebRTC SDP/ICE structure — malformed payloads only fail on the _receiving_\n client when it tries to parse/apply them, not at the relay. It does,\n however, enforce a byte-size ceiling (`MaxSignalPayloadBytes`) before\n relaying at all.\n- **Guard against double-submit** on room actions (create/join/close): each\n call is a distinct request, so two separate calls are two real operations\n server-side (e.g. two `createRoom` calls can both succeed as two different\n rooms if the first response is slow). Disable the control while a call is\n in flight.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full shape of\n`RoomSnapshotResponse`, `RoomMemberView`, `RealtimeEnvelope`/`EnvelopeType`,\n`MultiplayerDefinitions`, and the request/action surface, plus the room\nlifecycle state machine (Open → Closed, host migration, TTL expiry). Read it\nwhen building richer room/lobby UI or wiring up the poll loop's envelope\nhandling in detail.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Multiplayer data model — reference\n\nFull shape of the config (`MultiplayerDefinitions`), the room/member view\nmodels, the realtime envelope union, and the request/action surface. All of\nthese are **strictly typed in the SDK** and exported from `@idosgames/core`.\nThe zod schemas keep `.passthrough()`, so a field the backend adds later still\nround-trips as `unknown` extra data rather than being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\nThere is no player-state shape to document here — Multiplayer has none. Every\nmodel below is either title-wide config or an ephemeral, per-room view.\n\n## Contents\n\n- [Config: MultiplayerDefinitions](#config-multiplayerdefinitions) — what rides in on the title's public config\n- [Room lifecycle state machine](#room-lifecycle-state-machine)\n- [RoomSnapshotResponse](#roomsnapshotresponse)\n- [RoomMemberView](#roommemberview)\n- [RoomListItem](#roomlistitem--roomspageresponse)\n- [RealtimeEnvelope + EnvelopeType](#realtimeenvelope--envelopetype)\n- [LeaveRoomResponse / PublishResponse / PollResponse](#other-responses)\n- [Request + actions](#request--actions)\n\n---\n\n## Config: MultiplayerDefinitions\n\nBackend: `MultiplayerDefinitions.cs`, stored at\n`TitlePublicConfigurationModel.Multiplayer`. Public-only data — no secrets.\nThere is no dedicated `getMultiplayerDefinitions()` fetch; this arrives with\nthe full title config (`client.title.getTitlePublicConfiguration()`, or\nalready present after the login bootstrap). See\n[Reading limits from config](../SKILL.md#reading-limits-from-config) in the\nmain skill.\n\n```ts\ninterface MultiplayerDefinitions {\n SystemState?: { Enabled?: boolean }; // title-wide feature switch\n DefaultTopology?: MultiplayerTopology; // \"Mesh\" | \"Host\"; default \"Mesh\"\n AllowCreatorTopologyOverride?: boolean; // default true\n DefaultChatMode?: MultiplayerChatMode; // \"ServerRelay\" | \"P2P\"; default \"ServerRelay\"\n AllowCreatorChatOverride?: boolean; // default true\n MaxPlayersPerRoom?: number; // hard cap; default 8\n RoomTtlSeconds?: number; // Redis key TTL, renewed on every interaction; default 3600\n HeartbeatIntervalMs?: number; // recommended client poll interval; default 1000 — NOT enforced\n MemberTimeoutMs?: number; // client-side \"presumed offline\" threshold; default 15000 — no server auto-eviction\n MaxSignalPayloadBytes?: number; // default 16384\n MaxChatTextLength?: number; // characters; default 500\n RoomLogCap?: number; // how many broadcast envelopes the room-log tail keeps; default 500\n IceServers?: IceServer[]; // empty/absent -> server falls back to public Google STUN\n}\n\ninterface IceServer {\n Urls?: string; // e.g. \"stun:stun.l.google.com:19302\" or \"turn:turn.example.com:3478\"\n Username?: string; // TURN only\n Credential?: string; // TURN only\n}\n```\n\n`MultiplayerTopology`: `\"Mesh\"` (every player connects to every other —\nsmall rooms) | `\"Host\"` (star topology, everyone connects only to the host).\n\n`MultiplayerChatMode`: `\"ServerRelay\"` (chat broadcast through the room-log,\nworks in lobby and as a fallback) | `\"P2P\"` (server does not process chat at\nall — send it over your own DataChannel).\n\n`RoomVisibility`: `\"Public\"` (appears in `listRooms`) | `\"Private\"` (not\nlisted; join by `RoomID` + join code only).\n\n---\n\n## Room lifecycle state machine\n\nA room's `Status` (surfaced as `RoomSnapshotResponse.Status`) is a plain\nstring, driven by these transitions (backend: `RoomService.cs` +\n`RedisService.Realtime.cs`):\n\n- **created** → `\"Open\"`. `createRoom` sets it; the caller becomes both\n `OwnerUserID` and `HostUserID`.\n- **join** — allowed only while `Open` and under `MaxPlayers`; rejected with\n `\"Room is closed.\"` / `\"Room is full.\"` otherwise. A private room additionally\n requires the join code to match (SHA-256 hash comparison server-side —\n the raw code is never stored).\n- **leave** — if the leaving member was the last one, the room is deleted\n outright (`Closed: true` in the `LeaveRoomResponse`, no `RoomClosed`\n envelope is published — there's nobody left to receive it). If members\n remain and the leaver was the host, a new host is picked from the\n remaining members (`NewHostUserID`) and a `HostChanged` envelope is\n broadcast; the room stays `Open`.\n- **close** (`closeRoom`, owner or current host only) → a `RoomClosed`\n envelope is broadcast _before_ the room is marked `Closed`, so in-flight\n pollers still receive the notice; the room's keys then live out their\n remaining TTL rather than being deleted immediately (so late polls still\n resolve cleanly instead of hitting \"gone\").\n- **idle expiry** — every interaction (`poll`, `sendSignal`, `sendChat`,\n `setMemberState`, join/leave) renews the room's Redis TTL\n (`RoomTtlSeconds`). Stop interacting and the room silently disappears; a\n subsequent `poll` on it fails with `\"Room no longer exists.\"` (internally\n the `\"gone\"` code) rather than describing expiry explicitly.\n\nThere is no separate \"starting\"/\"in-progress\" server-tracked status — once\npeers have exchanged signals and opened their DataChannel, that phase is\nentirely off the server's radar. If your game needs a \"match started\" state,\nmodel it yourself via `setMemberState`'s free-form `MemberState` map or your\nown signaling payloads.\n\n---\n\n## RoomSnapshotResponse\n\nReturned by `createRoom`, `joinRoom`, and `getRoom`.\n\n```ts\ninterface RoomSnapshotResponse {\n RoomID?: string;\n Name?: string;\n HostUserID?: string; // current host; may differ from OwnerUserID after migration\n OwnerUserID?: string; // original creator; fixed for the room's lifetime\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n Visibility?: RoomVisibility;\n Status?: string; // \"Open\" | \"Closed\"\n MaxPlayers?: number;\n Members?: RoomMemberView[]; // ordered by JoinedAt ascending\n IceServers?: IceServer[]; // resolved (title config, or Google STUN fallback)\n Seq?: number; // starting poll cursor for this room\n}\n```\n\n`JoinCode` is deliberately never returned in any response — only its SHA-256\nhash is stored server-side, checked against what `joinRoom` submits.\n\n---\n\n## RoomMemberView\n\nOne room member: static public profile fields plus mutable lobby state.\n\n```ts\ninterface RoomMemberView {\n UserID?: string;\n Username?: string;\n AvatarUrl?: string;\n Level?: number; // snapshotted from the player's public profile at join time\n Power?: number; // snapshotted from the player's public profile at join time\n Ready?: boolean; // mutated via setMemberState\n State?: Record<string, string>; // free-form lobby metadata, mutated via setMemberState\n JoinedAt?: number; // unix ms\n IsHost?: boolean; // computed on read: UserID === room's current HostUserID\n LastSeenMs?: number; // unix ms of last heartbeat (poll); compare against MemberTimeoutMs yourself\n}\n```\n\n`Level`/`Power` are a snapshot taken from the player's public profile\n(`UserPublicDataModel`) at the moment they joined — they do **not** live-update\nif the player's Character Power changes mid-room. Re-`joinRoom` (or a fresh\n`getRoom`) is the only way to refresh them; there's no push for profile\nchanges into an existing member record.\n\n`IsHost` and `LastSeenMs` are computed server-side on every read (from\n`hostUserId` in room meta and the presence Sorted Set respectively) — they\nare not stored on the member record itself.\n\n---\n\n## RoomListItem + RoomsPageResponse\n\nReturned by `listRooms`. A deliberately thin projection — no `Members`, no\njoin code, no `Seq` — for cheap listing pages.\n\n```ts\ninterface RoomListItem {\n RoomID?: string;\n Name?: string;\n HostUserID?: string;\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n MaxPlayers?: number;\n MemberCount?: number;\n CreatedAt?: number; // unix ms\n}\n\ninterface RoomsPageResponse {\n Rooms?: RoomListItem[];\n Page?: number;\n PageSize?: number; // server-echoed; clamped to <= 50 regardless of what you requested\n HasMore?: boolean;\n}\n```\n\nOnly `Public`-visibility, currently-`Open` rooms ever appear here — private\nrooms and closed rooms are excluded at the index level, not filtered\nclient-side.\n\n---\n\n## RealtimeEnvelope + EnvelopeType\n\nThe unified delivery item returned by `poll`. `Seq` is a per-room, strictly\nincreasing counter shared by both the broadcast log and every member's\ndirected inbox — that's what makes a single `lastSeq` cursor sufficient to\ndrain all of it.\n\n```ts\ntype EnvelopeType =\n | \"Signal\" // directed: SDP offer/answer or ICE candidate\n | \"Chat\" // broadcast: ChatMode=ServerRelay message\n | \"PlayerJoined\" // broadcast: someone joined\n | \"PlayerLeft\" // broadcast: someone left\n | \"HostChanged\" // broadcast: host migrated\n | \"RoomClosed\" // broadcast: room closed by owner/host\n | \"MemberState\"; // broadcast: someone's Ready/State changed\n\ninterface RealtimeEnvelope {\n Seq?: number;\n Type?: EnvelopeType;\n FromUserID?: string;\n ToUserID?: string; // present only on directed \"Signal\" envelopes\n Kind?: string; // \"Signal\" only: \"offer\" | \"answer\" | \"candidate\" (free-form, by convention)\n Payload?: string; // opaque string; meaning depends on Type (see below)\n Ts?: number; // unix ms, server-assigned\n}\n```\n\n`Payload` by `Type`:\n\n- `\"Signal\"` → whatever string you passed to `sendSignal` (typically\n JSON-stringified `RTCSessionDescriptionInit` or `RTCIceCandidateInit`).\n- `\"Chat\"` → the raw chat text from `sendChat`.\n- `\"PlayerJoined\"` / `\"PlayerLeft\"` → the affected user's `UserID` (also\n duplicated in `FromUserID`).\n- `\"HostChanged\"` → the new host's `UserID`.\n- `\"RoomClosed\"` → the `RoomID` that was closed.\n- `\"MemberState\"` → a JSON string of `{ Ready, State }` — the changed\n member's new ready flag and state map (parse it, or just treat the\n envelope as a signal to re-`getRoom`).\n\nEnvelopes are delivered **at most once** per `poll` call (they're consumed by\nbeing read above `lastSeq`, not re-sent), but nothing deduplicates _across_\noverlapping poll calls if you mismanage your cursor — always advance\n`lastSeq` to the response's `MaxSeq`, never roll it back.\n\n---\n\n## Other responses\n\n```ts\ninterface LeaveRoomResponse {\n Closed?: boolean; // true if the room was closed/removed as a result\n NewHostUserID?: string; // set only when the host left and members remain\n}\n\ninterface PollResponse {\n Envelopes?: RealtimeEnvelope[]; // ordered by Seq ascending\n MaxSeq?: number; // max Seq among returned envelopes, or the sent LastSeq if none — always your next cursor\n}\n\ninterface PublishResponse {\n Seq?: number; // the seq assigned to the envelope you just published, for tracing/debugging\n}\n```\n\n---\n\n## Request + actions\n\nEvery call funnels through one `MultiplayerRequest` shape and a 10-value\n`MultiplayerAction` enum (`CreateRoom`, `JoinRoom`, `LeaveRoom`, `ListRooms`,\n`GetRoom`, `CloseRoom`, `Poll`, `SendSignal`, `SendChat`, `SetMemberState`) —\nyou never construct either yourself; `MultiplayerService` builds the request\nand picks the action per method. Listed here only so error messages and field\nnames in server logs/network tabs are recognizable:\n\n```ts\ninterface MultiplayerRequest {\n RoomID?: string;\n RoomName?: string; // CreateRoom\n Topology?: MultiplayerTopology; // CreateRoom\n ChatMode?: MultiplayerChatMode; // CreateRoom\n MaxPlayers?: number; // CreateRoom\n Visibility?: RoomVisibility; // CreateRoom\n JoinCode?: string; // CreateRoom (sets it) / JoinRoom (submits it)\n TargetUserID?: string; // SendSignal\n SignalKind?: string; // SendSignal: \"offer\" | \"answer\" | \"candidate\"\n Payload?: string; // SendSignal\n ChatText?: string; // SendChat\n Ready?: boolean; // SetMemberState\n MemberState?: Record<string, string>; // SetMemberState\n LastSeq?: number; // Poll cursor\n Page?: number; // ListRooms\n PageSize?: number; // ListRooms\n}\n```\n\nPer-action rate limiting (server-side, not SDK-visible as distinct reasons —\nall surface as `reason: \"throttled\"` from the transport, or a plain\n`\"server\"` failure if you exceed the backend's own request lock): `Poll`,\n`SendSignal`, `SendChat`, and `SetMemberState` are treated as high-frequency\nrealtime traffic with a short per-user lock; `ListRooms`/`GetRoom` are\nread-only with a medium lock; `CreateRoom`/`JoinRoom`/`LeaveRoom`/`CloseRoom`\n(room lifecycle mutations) use the strictest lock. You don't need the exact\nnumbers to use the SDK correctly — just don't hot-loop any of these calls,\nand expect lifecycle actions (create/join/leave/close) to tolerate less\nback-to-back traffic than the realtime loop (poll/signal/chat/state).\n"
|
|
8
|
+
"content": "# Multiplayer data model — reference\n\nFull shape of the config (`MultiplayerDefinitions`), the room/member view\nmodels, the realtime envelope union, and the request/action surface. All of\nthese are **strictly typed in the SDK** and exported from `@idosgames/core`.\nThe zod schemas keep `.passthrough()`, so a field the backend adds later still\nround-trips as `unknown` extra data rather than being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\nThere is no player-state shape to document here — Multiplayer has none. Every\nmodel below is either title-wide config or an ephemeral, per-room view.\n\n## Contents\n\n- [Config: MultiplayerDefinitions](#config-multiplayerdefinitions) — what rides in on the title's public config\n- [Room lifecycle state machine](#room-lifecycle-state-machine)\n- [RoomSnapshotResponse](#roomsnapshotresponse)\n- [RoomMemberView](#roommemberview)\n- [RoomListItem](#roomlistitem--roomspageresponse)\n- [RealtimeEnvelope + EnvelopeType](#realtimeenvelope--envelopetype)\n- [LeaveRoomResponse / PublishResponse / PollResponse](#other-responses)\n- [Request + actions](#request--actions)\n\n---\n\n## Config: MultiplayerDefinitions\n\nBackend: `MultiplayerDefinitions.cs`, stored at\n`TitlePublicConfigurationModel.Multiplayer`. Public-only data — no secrets.\nThere is no dedicated `getMultiplayerDefinitions()` fetch; this arrives with\nthe full title config (`client.title.getTitlePublicConfiguration()`, or\nalready present after the login bootstrap). See\n[Reading limits from config](../SKILL.md#reading-limits-from-config) in the\nmain skill.\n\n```ts\ninterface MultiplayerDefinitions {\n SystemState?: { Enabled?: boolean }; // title-wide feature switch\n DefaultTopology?: MultiplayerTopology; // \"Mesh\" | \"Host\"; default \"Mesh\"\n AllowCreatorTopologyOverride?: boolean; // default true\n DefaultChatMode?: MultiplayerChatMode; // \"ServerRelay\" | \"P2P\"; default \"ServerRelay\"\n AllowCreatorChatOverride?: boolean; // default true\n MaxPlayersPerRoom?: number; // hard cap; default 8\n RoomTtlSeconds?: number; // Redis key TTL, renewed on every interaction; default 3600\n HeartbeatIntervalMs?: number; // recommended client poll interval; default 1000 — NOT enforced\n MemberTimeoutMs?: number; // \"presumed offline\" threshold; default 15000. The server evicts nobody on its own — except a HOST silent this long, via claimHost\n MaxSignalPayloadBytes?: number; // default 16384\n MaxChatTextLength?: number; // characters; default 500\n RoomLogCap?: number; // how many broadcast envelopes the room-log tail keeps; default 500\n IceServers?: IceServer[]; // STUN only here (public config!); empty/absent -> public STUN fallback. TURN comes per member from the engine (Cloudflare), never from this list\n}\n\ninterface IceServer {\n Urls?: string; // e.g. \"stun:stun.l.google.com:19302\" or \"turn:turn.example.com:3478\"\n Username?: string; // TURN only\n Credential?: string; // TURN only\n}\n```\n\n`MultiplayerTopology`: `\"Mesh\"` (every player connects to every other —\nsmall rooms) | `\"Host\"` (star topology, everyone connects only to the host).\n\n`MultiplayerChatMode`: `\"ServerRelay\"` (chat broadcast through the room-log,\nworks in lobby and as a fallback) | `\"P2P\"` (server does not process chat at\nall — send it over your own DataChannel).\n\n`RoomVisibility`: `\"Public\"` (appears in `listRooms`) | `\"Private\"` (not\nlisted; join by `RoomID` + join code only).\n\n---\n\n## Room lifecycle state machine\n\nA room's `Status` (surfaced as `RoomSnapshotResponse.Status`) is a plain\nstring, driven by these transitions (backend: `RoomService.cs` +\n`RedisService.Realtime.cs`):\n\n- **created** → `\"Open\"`. `createRoom` sets it; the caller becomes both\n `OwnerUserID` and `HostUserID`.\n- **join** — allowed only while `Open` and under `MaxPlayers`; rejected with\n `\"Room is closed.\"` / `\"Room is full.\"` otherwise. A private room additionally\n requires the join code to match (SHA-256 hash comparison server-side —\n the raw code is never stored).\n- **leave** — if the leaving member was the last one, the room is deleted\n outright (`Closed: true` in the `LeaveRoomResponse`, no `RoomClosed`\n envelope is published — there's nobody left to receive it). If members\n remain and the leaver was the host, a new host is picked from the\n remaining members (`NewHostUserID`) and a `HostChanged` envelope is\n broadcast; the room stays `Open`.\n- **leave with a successor** — a leaving host may pass `successorUserID`; if\n that player is still a member they become host (otherwise the server picks\n one). `RealtimeSession` does this on `leave()` and on page close.\n- **claimHost** — a member takes the host role when the host has not polled\n for `MemberTimeoutMs`: the silent host is removed, `PlayerLeft` (Kind\n `\"timeout\"`) and `HostChanged` are broadcast. A compare-and-set in Redis\n guarantees one winner; a still-polling host → `\"The host is still active.\"`.\n- **kick / ban** (`kickMember`, owner or current host; never the host or the\n owner themselves) — `PlayerLeft` (Kind `\"kicked\"`). With `ban` the player is\n added to the room's ban list, so `joinRoom` answers `\"You were removed from\nthis room.\"`. The list survives host changes and dies with the room;\n `unbanMember` removes an entry.\n- **close** (`closeRoom`, owner or current host only) → a `RoomClosed`\n envelope is broadcast _before_ the room is marked `Closed`, so in-flight\n pollers still receive the notice; the room's keys then live out their\n remaining TTL rather than being deleted immediately (so late polls still\n resolve cleanly instead of hitting \"gone\").\n- **idle expiry** — every interaction (`poll`, `sendSignal`, `sendChat`,\n `setMemberState`, join/leave) renews the room's Redis TTL\n (`RoomTtlSeconds`). Stop interacting and the room silently disappears; a\n subsequent `poll` on it fails with `\"Room no longer exists.\"` (internally\n the `\"gone\"` code) rather than describing expiry explicitly.\n\nThere is no separate \"starting\"/\"in-progress\" server-tracked status — once\npeers have exchanged signals and opened their DataChannel, that phase is\nentirely off the server's radar. If your game needs a \"match started\" state,\nmodel it yourself via `setMemberState`'s free-form `MemberState` map or your\nown signaling payloads.\n\n---\n\n## RoomSnapshotResponse\n\nReturned by `createRoom`, `joinRoom`, and `getRoom`.\n\n```ts\ninterface RoomSnapshotResponse {\n RoomID?: string;\n Name?: string;\n HostUserID?: string; // current host; may differ from OwnerUserID after migration\n OwnerUserID?: string; // original creator; fixed for the room's lifetime\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n Visibility?: RoomVisibility;\n Status?: string; // \"Open\" | \"Closed\"\n MaxPlayers?: number;\n Members?: RoomMemberView[]; // ordered by JoinedAt ascending\n IceServers?: IceServer[]; // resolved: title STUN (or public fallback) + per-member TURN credentials when the engine has Cloudflare TURN\n Seq?: number; // starting poll cursor for this room\n BannedUserIDs?: string[]; // the ban list — only in the host's / owner's snapshot\n}\n```\n\n`JoinCode` is deliberately never returned in any response — only its SHA-256\nhash is stored server-side, checked against what `joinRoom` submits.\n\n---\n\n## RoomMemberView\n\nOne room member: static public profile fields plus mutable lobby state.\n\n```ts\ninterface RoomMemberView {\n UserID?: string;\n Username?: string;\n AvatarUrl?: string;\n Level?: number; // snapshotted from the player's public profile at join time\n Power?: number; // snapshotted from the player's public profile at join time\n Ready?: boolean; // mutated via setMemberState\n State?: Record<string, string>; // free-form lobby metadata, mutated via setMemberState\n JoinedAt?: number; // unix ms\n IsHost?: boolean; // computed on read: UserID === room's current HostUserID\n LastSeenMs?: number; // unix ms of last heartbeat (poll); compare against MemberTimeoutMs yourself\n}\n```\n\n`Level`/`Power` are a snapshot taken from the player's public profile\n(`UserPublicDataModel`) at the moment they joined — they do **not** live-update\nif the player's Character Power changes mid-room. Re-`joinRoom` (or a fresh\n`getRoom`) is the only way to refresh them; there's no push for profile\nchanges into an existing member record.\n\n`IsHost` and `LastSeenMs` are computed server-side on every read (from\n`hostUserId` in room meta and the presence Sorted Set respectively) — they\nare not stored on the member record itself.\n\n---\n\n## RoomListItem + RoomsPageResponse\n\nReturned by `listRooms`. A deliberately thin projection — no `Members`, no\njoin code, no `Seq` — for cheap listing pages.\n\n```ts\ninterface RoomListItem {\n RoomID?: string;\n Name?: string;\n HostUserID?: string;\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n MaxPlayers?: number;\n MemberCount?: number;\n CreatedAt?: number; // unix ms\n}\n\ninterface RoomsPageResponse {\n Rooms?: RoomListItem[];\n Page?: number;\n PageSize?: number; // server-echoed; clamped to <= 50 regardless of what you requested\n HasMore?: boolean;\n}\n```\n\nOnly `Public`-visibility, currently-`Open` rooms ever appear here — private\nrooms and closed rooms are excluded at the index level, not filtered\nclient-side.\n\n---\n\n## RealtimeEnvelope + EnvelopeType\n\nThe unified delivery item returned by `poll`. `Seq` is a per-room, strictly\nincreasing counter shared by both the broadcast log and every member's\ndirected inbox — that's what makes a single `lastSeq` cursor sufficient to\ndrain all of it.\n\n```ts\ntype EnvelopeType =\n | \"Signal\" // directed: SDP offer/answer or ICE candidate\n | \"Chat\" // broadcast: ChatMode=ServerRelay message\n | \"PlayerJoined\" // broadcast: someone joined\n | \"PlayerLeft\" // broadcast: someone left\n | \"HostChanged\" // broadcast: host migrated\n | \"RoomClosed\" // broadcast: room closed by owner/host\n | \"MemberState\"; // broadcast: someone's Ready/State changed\n\ninterface RealtimeEnvelope {\n Seq?: number;\n Type?: EnvelopeType;\n FromUserID?: string;\n ToUserID?: string; // present only on directed \"Signal\" envelopes\n Kind?: string; // \"Signal\": free-form (\"offer\" | \"answer\" | \"candidate\" by convention; RealtimeSession uses \"rtc\");\n // \"PlayerLeft\": \"kicked\" (FromUserID = who kicked, Payload = kicked player) or\n // \"timeout\" (host replaced via claimHost); absent for a plain leave\n Payload?: string; // opaque string; meaning depends on Type (see below)\n Ts?: number; // unix ms, server-assigned\n}\n```\n\n`Payload` by `Type`:\n\n- `\"Signal\"` → whatever string you passed to `sendSignal` (typically\n JSON-stringified `RTCSessionDescriptionInit` or `RTCIceCandidateInit`).\n- `\"Chat\"` → the raw chat text from `sendChat`.\n- `\"PlayerJoined\"` / `\"PlayerLeft\"` → the affected user's `UserID` (also\n duplicated in `FromUserID`).\n- `\"HostChanged\"` → the new host's `UserID`.\n- `\"RoomClosed\"` → the `RoomID` that was closed.\n- `\"MemberState\"` → a JSON string of `{ Ready, State }` — the changed\n member's new ready flag and state map (parse it, or just treat the\n envelope as a signal to re-`getRoom`).\n\nEnvelopes are delivered **at most once** per `poll` call (they're consumed by\nbeing read above `lastSeq`, not re-sent), but nothing deduplicates _across_\noverlapping poll calls if you mismanage your cursor — always advance\n`lastSeq` to the response's `MaxSeq`, never roll it back.\n\n---\n\n## Other responses\n\n```ts\ninterface KickMemberResponse {\n Removed?: boolean; // the player was a member and has been removed\n Banned?: boolean; // the player is now on the ban list\n}\n\ninterface UnbanMemberResponse {\n WasBanned?: boolean;\n}\n\ninterface LeaveRoomResponse {\n Closed?: boolean; // true if the room was closed/removed as a result\n NewHostUserID?: string; // set only when the host left and members remain\n}\n\ninterface PollResponse {\n Envelopes?: RealtimeEnvelope[]; // ordered by Seq ascending\n MaxSeq?: number; // max Seq among returned envelopes, or the sent LastSeq if none — always your next cursor\n}\n\ninterface PublishResponse {\n Seq?: number; // the seq assigned to the envelope you just published, for tracing/debugging\n}\n```\n\n---\n\n## Request + actions\n\nEvery call funnels through one `MultiplayerRequest` shape and a 13-value\n`MultiplayerAction` enum (`CreateRoom`, `JoinRoom`, `LeaveRoom`, `ListRooms`,\n`GetRoom`, `CloseRoom`, `Poll`, `SendSignal`, `SendChat`, `SetMemberState`,\n`ClaimHost`, `KickMember`, `UnbanMember`) —\nyou never construct either yourself; `MultiplayerService` builds the request\nand picks the action per method. Listed here only so error messages and field\nnames in server logs/network tabs are recognizable:\n\n```ts\ninterface MultiplayerRequest {\n RoomID?: string;\n RoomName?: string; // CreateRoom\n Topology?: MultiplayerTopology; // CreateRoom\n ChatMode?: MultiplayerChatMode; // CreateRoom\n MaxPlayers?: number; // CreateRoom\n Visibility?: RoomVisibility; // CreateRoom\n JoinCode?: string; // CreateRoom (sets it) / JoinRoom (submits it)\n TargetUserID?: string; // SendSignal / KickMember / UnbanMember\n SuccessorUserID?: string; // LeaveRoom: the host names who takes over\n Ban?: boolean; // KickMember: also ban\n SignalKind?: string; // SendSignal: \"offer\" | \"answer\" | \"candidate\"\n Payload?: string; // SendSignal\n ChatText?: string; // SendChat\n Ready?: boolean; // SetMemberState\n MemberState?: Record<string, string>; // SetMemberState\n LastSeq?: number; // Poll cursor\n Page?: number; // ListRooms\n PageSize?: number; // ListRooms\n}\n```\n\nPer-action rate limiting (server-side, not SDK-visible as distinct reasons —\nall surface as `reason: \"throttled\"` from the transport, or a plain\n`\"server\"` failure if you exceed the backend's own request lock): `Poll`,\n`SendSignal`, `SendChat`, and `SetMemberState` are treated as high-frequency\nrealtime traffic with a short per-user lock; `ListRooms`/`GetRoom` are\nread-only with a medium lock; `CreateRoom`/`JoinRoom`/`LeaveRoom`/`CloseRoom`\n(room lifecycle mutations) use the strictest lock. You don't need the exact\nnumbers to use the SDK correctly — just don't hot-loop any of these calls,\nand expect lifecycle actions (create/join/leave/close) to tolerate less\nback-to-back traffic than the realtime loop (poll/signal/chat/state).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "referral-system",
|
|
3
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",
|
|
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// Web-only title (no Google Play / App Store / Telegram link): \".../app/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=...`, or\n `/app/{titleID}?ref=...` for a web-only title) 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\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"
|
|
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}` when the\n title has a Google Play, App Store or Telegram link (`Attribution.StoreLinks`), otherwise the\n web app page `idosgames.com/app/{titleID}/?ref={code}` (no one-button \"pick a platform\" step).\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
|
}
|