@idosgames/mcp 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -5
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +16 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/authentication.json +3 -3
- package/registry/skills/collection-system.json +1 -1
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/game-loop-system.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-title-bootstrap.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/social-system.json +1 -1
- package/registry/skills/user-profile.json +2 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Item data model — reference\r\n\r\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\r\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\r\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\r\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\r\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\r\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\r\n`.passthrough()`, so a field the backend adds later still round-trips. Field\r\nnames are PascalCase (straight from the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\r\n- [ItemCatalog](#itemcatalog)\r\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\r\n- [ItemDefinition](#itemdefinition)\r\n- [ItemStats](#itemstats)\r\n- [ItemEquipment](#itemequipment)\r\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\r\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\r\n- [ItemMetadata](#itemmetadata)\r\n- [NFTModel](#nftmodel)\r\n- [Player state: InventoryV2](#player-state-inventoryv2)\r\n- [Requests, responses, and actions](#requests-responses-and-actions)\r\n\r\n---\r\n\r\n## Config: ItemDefinitions\r\n\r\nRoot container for every item catalog in the title.\r\n\r\n```ts\r\ninterface ItemDefinitions {\r\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\r\n}\r\n```\r\n\r\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\r\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\r\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\r\nthe exported type is pinned to a hand-written interface instead.\r\n\r\n## ItemCatalog\r\n\r\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\r\n\r\n```ts\r\ninterface ItemCatalog {\r\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\r\n}\r\n```\r\n\r\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\r\nguaranteed unique **within** a catalog — the same `ItemID` string can\r\nlegitimately appear in more than one catalog, which is exactly what the\r\nresolution rule below has to handle.\r\n\r\n## Catalog resolution rule\r\n\r\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\r\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\r\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\r\ncall this yourself, but its behavior explains error messages and a\r\nself-healing field you'll see on upgrade responses:\r\n\r\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\r\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\r\n unambiguous by construction.\r\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\r\n (the item was moved to a different catalog since the instance was granted),\r\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\r\n that's the resolved definition.\r\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\r\n more** catalogs, the resolver refuses to guess and returns nothing (the\r\n caller reports \"item definition not found\").\r\n\r\n**Self-heal:** when resolution succeeds via the fallback path with a\r\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\r\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\r\none as part of the same atomic write — silently, no separate event. That's why\r\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\r\nthe instance before calling upgrade: read it back off the response / refreshed\r\ncache, don't assume it's unchanged.\r\n\r\n---\r\n\r\n## ItemDefinition\r\n\r\nThe template from which player instances are created.\r\n\r\n```ts\r\ninterface ItemDefinition {\r\n ItemID: string;\r\n CatalogID: string;\r\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\r\n DisplayName?: string;\r\n Description?: string;\r\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\r\n CustomData?: string;\r\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\r\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\r\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\r\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\r\n NFT?: NFTModel; // blockchain binding, if any\r\n Stats?: ItemStats;\r\n Equipment?: ItemEquipment;\r\n Upgrade?: ItemUpgrade;\r\n Metadata?: ItemMetadata;\r\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\r\n}\r\n```\r\n\r\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\r\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\r\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\r\nconsumable can still have upgrade tiers, and an equippable item can be\r\nnon-upgradable.\r\n\r\n---\r\n\r\n## ItemStats\r\n\r\nStat modifiers/Power the item contributes when equipped. Applied in two\r\nlayers — flat bonuses added to the base stat first, then percent bonuses\r\nmultiply the (base + flat) total. Consumed by the Character module's Power\r\ncomputation (see character-system skill) — never recomputed client-side.\r\n\r\n```ts\r\ninterface ItemStats {\r\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\r\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\r\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\r\n}\r\n```\r\n\r\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\r\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\r\n\r\n---\r\n\r\n## ItemEquipment\r\n\r\nThe item-side half of the two-sided equip rule matrix (the character-side\r\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\r\nreference doc — both must pass for an equip to succeed).\r\n\r\n```ts\r\ninterface ItemEquipment {\r\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\r\n UseRequirements?: Record<string, number>; // statID -> required character stat level\r\n AllowedCharacterIDs?: string[]; // null/empty = any character\r\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\r\n}\r\n```\r\n\r\n---\r\n\r\n## ItemUpgrade + cost formula\r\n\r\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch`.\r\n\r\n```ts\r\ninterface ItemUpgrade {\r\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\r\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\r\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\r\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\r\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\r\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\r\n}\r\n```\r\n\r\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\r\nnot the step count):\r\n\r\n```\r\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\r\n// BaseCost = the Cost of the selected PriceOptions option\r\n```\r\n\r\n— identical semantics to the Character module's stat-cost scaling, and the same shared\r\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\r\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\r\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\r\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\r\nfrom `current + 1` through the resolved target — it is not a single jump priced\r\noff the destination level alone. If every scaled amount rounds to `0`, or the\r\nselected option is empty, the upgrade is rejected as misconfigured rather than\r\ntreated as free.\r\n\r\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\r\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\r\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\r\nfor the first option available on the caller's platform.\r\n\r\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\r\nthrough unchanged and resolved by the shared premium pipeline per level before\r\nthe per-level bundles are summed — see the currency-system skill for\r\n`ResourceConsume`'s premium fields.\r\n\r\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\r\nwith `firstStep = 1`, so level 1 is the plain base):\r\n\r\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\r\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\r\n before aggregation into the character's total gear-percent.\r\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\r\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\r\n balance any double-counting themselves via weights).\r\n\r\nAn **unset** curve means that quantity does not grow with level — only the base value\r\napplies at every level. There is no field here whose neutral value is `1`: empty is the\r\nneutral, always. These three are read-only\r\ninputs to server computations (Power, PvP stat calc); the SDK never\r\nrecomputes them for you.\r\n\r\n---\r\n\r\n## Fodder valuation + selection modes\r\n\r\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\r\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\r\nand picked when the upgrade's own cost is expressed in copies of itself. Any\r\nother cost entries (currencies, other items, event tokens) are charged\r\nnormally through the regular resource pipeline regardless of `Fodder` config.\r\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\r\n`ProtectLeveled` selection.\r\n\r\n```ts\r\ninterface ItemUpgradeFodder {\r\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\r\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\r\n Selection?: \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\";\r\n}\r\n```\r\n\r\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\r\n\r\nThe server expresses the self-item portion of the upgrade cost as a **target\r\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\r\nburns fodder copies until their summed `W(level)` meets or exceeds that\r\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\r\n\r\n| Mode | `W(L)` formula | Notes |\r\n| ------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\r\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\r\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\r\n\r\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\r\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\r\nclient-side for a cost preview, but the server is what actually enforces\r\ncoverage.\r\n\r\n### Selection — which instances get burned\r\n\r\nOnly relevant when the caller doesn't already specify exact fodder for every\r\nunit needed (or when supply must be chosen automatically):\r\n\r\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\r\n eligible; anything the player has already leveled up is never auto-selected\r\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\r\n in the sense that the pool is still filtered this way in `FlatCount` mode\r\n (where fodder isn't weighted at all — see below).\r\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\r\n same-item/same-catalog, not equipped, not expired, not already claimed by\r\n another item in the same batch) are sorted by `W(level)` ascending, then by\r\n acquisition time, then by ID, and burned cheapest-first until the target\r\n value is covered. Leveled copies are eligible here and burn last (they're\r\n worth more per unit, so they're a poor early pick under this greedy order).\r\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\r\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\r\n validated individually (must exist, must be the same item/catalog, must not\r\n be equipped or expired, must not already be claimed elsewhere in the same\r\n batch) and rejected by name if any check fails. If the combined `W(level)`\r\n of your supplied instances doesn't cover the target, the whole upgrade is\r\n rejected — nothing is partially burned.\r\n\r\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\r\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\r\nthat mode the self-item cost is settled by the _regular_ resource-consume\r\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\r\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\r\nrather than being burned), and the rest come from plain inventory count, with\r\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\r\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\r\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\r\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\r\nself-item cost entry, or fodder that's a different item, is rejected.\r\n\r\n---\r\n\r\n## ItemMetadata\r\n\r\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\r\nsubsystem and general UI.\r\n\r\n```ts\r\ninterface ItemMetadata {\r\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\r\n CollectionID?: string; // ties the item into a Collection set/album page\r\n AuthorID?: string; // e.g. UGC/creator attribution\r\n}\r\n```\r\n\r\n---\r\n\r\n## NFTModel\r\n\r\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\r\nitem mirrored on both an EVM chain and Solana).\r\n\r\n```ts\r\ninterface NFTModel {\r\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\r\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\r\n}\r\n\r\ninterface NFTNetworkBinding {\r\n ContractAddress?: string; // EVM contract or Solana mint address\r\n TokenID?: string;\r\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\r\n}\r\n```\r\n\r\nSee the blockchain-system skill for the wallet/mint/transfer flows that\r\npopulate and consume this binding.\r\n\r\n---\r\n\r\n## Player state: InventoryV2\r\n\r\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\r\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch` (and other inventory-affecting calls).\r\n\r\n```ts\r\ninterface UserInventoryState {\r\n Version?: number;\r\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\r\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\r\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\r\n ConversionDaily?: Record<string, ConversionDailyCounter>;\r\n}\r\n\r\ninterface ItemTotals {\r\n StackableAmount: number;\r\n UnstackableAmount: number;\r\n TotalAmount: number;\r\n}\r\n\r\ninterface UnstackableItemInstanceState {\r\n ItemInstanceID: string;\r\n ItemID: string;\r\n CatalogID?: string | null;\r\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\r\n RemainingUses?: number; // consumable-with-charges items\r\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\r\n AcquiredAt: string;\r\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\r\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\r\n CustomData?: string | null;\r\n}\r\n```\r\n\r\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\r\ncurrency-system skill; they ride along in the same inventory snapshot but\r\naren't item-related.\r\n\r\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\r\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\r\nwhich half of the inventory backs it.\r\n\r\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\r\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\r\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\r\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\r\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\r\none copy — e.g. leveling one copy of a stack of five identical swords — the\r\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\r\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\r\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\r\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\r\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\r\ncome back as a **different** id than the one you called with — always read\r\nthe instance id off the response (or the refreshed cache), don't assume it's\r\nunchanged. The backend also opportunistically re-merges pristine fragments of\r\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\r\nyou don't need to do anything to trigger or handle that.\r\n\r\n---\r\n\r\n## Requests, responses, and actions\r\n\r\n```ts\r\ninterface ItemRequest extends BaseRequest {\r\n ItemInstanceID?: string;\r\n Levels?: number;\r\n TargetLevel?: number;\r\n FodderInstanceIDs?: string[];\r\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\r\n Upgrades?: ItemUpgradeRef[];\r\n}\r\n```\r\n\r\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\r\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\r\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\r\n**not** expose them — it only ever raises by one level per call. To move\r\nseveral levels in one call (on one or many instances), use\r\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\r\n\r\n```ts\r\ninterface ItemUpgradeRef {\r\n ItemInstanceID?: string;\r\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\r\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\r\n FodderInstanceIDs?: string[];\r\n}\r\n\r\ninterface FodderConsumedEntry {\r\n ItemInstanceID: string;\r\n Units: number; // how many copies of this instance/pack were burned\r\n Level: number; // the fodder instance's level at time of consumption\r\n}\r\n\r\ninterface UpgradeItemLevelResponse {\r\n ServerTimeUtc: string;\r\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\r\n ItemID: string;\r\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\r\n Level: number; // new level after the upgrade\r\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\r\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\r\n}\r\n\r\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\r\n```\r\n\r\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\r\nuseful when reading logs/errors that echo the action):\r\n\r\n```ts\r\nconst ItemAction = {\r\n UpgradeLevel: \"UpgradeLevel\",\r\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\r\n} as const;\r\n```\r\n\r\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\r\nshape used across the whole SDK — see the currency-system skill for the full\r\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\r\n`PremiumDiscounts` can reduce a displayed base cost.\r\n\r\n### Server-side limits (verified against the backend)\r\n\r\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\r\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\r\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\r\n appear in the result array at all. Chunk larger sets yourself.\r\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\r\n id in the same call only processes once.\r\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\r\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\r\n (single call fails outright; batch reports it as a failed item).\r\n- **Atomicity**: both the single and batch charge/patch happen inside one\r\n Mongo transaction — either the whole thing (cost + level + any fodder burns\r\n - owner Power recompute) applies, or none of it does.\r\n- **Idempotency**: the backend replays the same result for a repeated call\r\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\r\n server-side reason key, so re-running the _same target level_ twice is safe\r\n to retry). The TS `upgradeLevel` method, however, mints a fresh\r\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\r\n from the SDK's side, two separate calls are always two separate operations;\r\n see the Gotchas section in SKILL.md.\r\n"
|
|
8
|
+
"content": "# Item data model — reference\r\n\r\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\r\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\r\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\r\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\r\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\r\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\r\n`.passthrough()`, so a field the backend adds later still round-trips. Field\r\nnames are PascalCase (straight from the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\r\n- [ItemCatalog](#itemcatalog)\r\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\r\n- [ItemDefinition](#itemdefinition)\r\n- [ItemStats](#itemstats)\r\n- [ItemEquipment](#itemequipment)\r\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\r\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\r\n- [ItemMetadata](#itemmetadata)\r\n- [NFTModel](#nftmodel)\r\n- [Player state: InventoryV2](#player-state-inventoryv2)\r\n- [Requests, responses, and actions](#requests-responses-and-actions)\r\n\r\n---\r\n\r\n## Config: ItemDefinitions\r\n\r\nRoot container for every item catalog in the title.\r\n\r\n```ts\r\ninterface ItemDefinitions {\r\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\r\n}\r\n```\r\n\r\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\r\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\r\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\r\nthe exported type is pinned to a hand-written interface instead.\r\n\r\n## ItemCatalog\r\n\r\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\r\n\r\n```ts\r\ninterface ItemCatalog {\r\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\r\n}\r\n```\r\n\r\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\r\nguaranteed unique **within** a catalog — the same `ItemID` string can\r\nlegitimately appear in more than one catalog, which is exactly what the\r\nresolution rule below has to handle.\r\n\r\n## Catalog resolution rule\r\n\r\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\r\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\r\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\r\ncall this yourself, but its behavior explains error messages and a\r\nself-healing field you'll see on upgrade responses:\r\n\r\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\r\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\r\n unambiguous by construction.\r\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\r\n (the item was moved to a different catalog since the instance was granted),\r\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\r\n that's the resolved definition.\r\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\r\n more** catalogs, the resolver refuses to guess and returns nothing (the\r\n caller reports \"item definition not found\").\r\n\r\n**Self-heal:** when resolution succeeds via the fallback path with a\r\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\r\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\r\none as part of the same atomic write — silently, no separate event. That's why\r\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\r\nthe instance before calling upgrade: read it back off the response / refreshed\r\ncache, don't assume it's unchanged.\r\n\r\n---\r\n\r\n## ItemDefinition\r\n\r\nThe template from which player instances are created.\r\n\r\n```ts\r\ninterface ItemDefinition {\r\n ItemID: string;\r\n CatalogID: string;\r\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\r\n DisplayName?: string;\r\n Description?: string;\r\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\r\n CustomData?: string;\r\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\r\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\r\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\r\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\r\n NFT?: NFTModel; // blockchain binding, if any\r\n Stats?: ItemStats;\r\n Equipment?: ItemEquipment;\r\n Upgrade?: ItemUpgrade;\r\n Metadata?: ItemMetadata;\r\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\r\n}\r\n```\r\n\r\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\r\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\r\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\r\nconsumable can still have upgrade tiers, and an equippable item can be\r\nnon-upgradable.\r\n\r\n---\r\n\r\n## ItemStats\r\n\r\nStat modifiers/Power the item contributes when equipped. Applied in two\r\nlayers — flat bonuses added to the base stat first, then percent bonuses\r\nmultiply the (base + flat) total. Consumed by the Character module's Power\r\ncomputation (see character-system skill) — never recomputed client-side.\r\n\r\n```ts\r\ninterface ItemStats {\r\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\r\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\r\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\r\n}\r\n```\r\n\r\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\r\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\r\n\r\n---\r\n\r\n## ItemEquipment\r\n\r\nThe item-side half of the two-sided equip rule matrix (the character-side\r\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\r\nreference doc — both must pass for an equip to succeed).\r\n\r\n```ts\r\ninterface ItemEquipment {\r\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\r\n UseRequirements?: Record<string, number>; // statID -> required character stat level\r\n AllowedCharacterIDs?: string[]; // null/empty = any character\r\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\r\n}\r\n```\r\n\r\n---\r\n\r\n## ItemUpgrade + cost formula\r\n\r\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch`.\r\n\r\n```ts\r\ninterface ItemUpgrade {\r\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\r\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\r\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\r\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\r\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\r\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\r\n}\r\n```\r\n\r\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\r\nnot the step count):\r\n\r\n```\r\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\r\n// BaseCost = the Cost of the selected PriceOptions option\r\n```\r\n\r\n— identical semantics to the Character module's stat-cost scaling, and the same shared\r\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\r\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\r\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\r\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\r\nfrom `current + 1` through the resolved target — it is not a single jump priced\r\noff the destination level alone. If every scaled amount rounds to `0`, or the\r\nselected option is empty, the upgrade is rejected as misconfigured rather than\r\ntreated as free.\r\n\r\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\r\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\r\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\r\nfor the first option available on the caller's platform.\r\n\r\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\r\nthrough unchanged and resolved by the shared premium pipeline per level before\r\nthe per-level bundles are summed — see the currency-system skill for\r\n`ResourceConsume`'s premium fields.\r\n\r\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\r\nwith `firstStep = 1`, so level 1 is the plain base):\r\n\r\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\r\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\r\n before aggregation into the character's total gear-percent.\r\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\r\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\r\n balance any double-counting themselves via weights).\r\n\r\nAn **unset** curve means that quantity does not grow with level — only the base value\r\napplies at every level. There is no field here whose neutral value is `1`: empty is the\r\nneutral, always. These three are read-only\r\ninputs to server computations (Power, PvP stat calc); the SDK never\r\nrecomputes them for you.\r\n\r\n---\r\n\r\n## Fodder valuation + selection modes\r\n\r\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\r\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\r\nand picked when the upgrade's own cost is expressed in copies of itself. Any\r\nother cost entries (currencies, other items, event tokens) are charged\r\nnormally through the regular resource pipeline regardless of `Fodder` config.\r\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\r\n`ProtectLeveled` selection.\r\n\r\n```ts\r\ninterface ItemUpgradeFodder {\r\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\r\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\r\n Selection?:\r\n | \"ProtectLeveled\"\r\n | \"CheapestFirst\"\r\n | \"ClientSelected\"\r\n | \"SameLevelOnly\";\r\n}\r\n```\r\n\r\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\r\n\r\nThe server expresses the self-item portion of the upgrade cost as a **target\r\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\r\nburns fodder copies until their summed `W(level)` meets or exceeds that\r\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\r\n\r\n| Mode | `W(L)` formula | Notes |\r\n| ------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\r\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\r\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\r\n\r\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\r\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\r\nclient-side for a cost preview, but the server is what actually enforces\r\ncoverage.\r\n\r\n### Selection — which instances get burned\r\n\r\nOnly relevant when the caller doesn't already specify exact fodder for every\r\nunit needed (or when supply must be chosen automatically):\r\n\r\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\r\n eligible; anything the player has already leveled up is never auto-selected\r\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\r\n in the sense that the pool is still filtered this way in `FlatCount` mode\r\n (where fodder isn't weighted at all — see below).\r\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\r\n same-item/same-catalog, not equipped, not expired, not already claimed by\r\n another item in the same batch) are sorted by `W(level)` ascending, then by\r\n acquisition time, then by ID, and burned cheapest-first until the target\r\n value is covered. Leveled copies are eligible here and burn last (they're\r\n worth more per unit, so they're a poor early pick under this greedy order).\r\n- **`SameLevelOnly`** — only instances **at the upgraded item's own level**\r\n are eligible: level 2 is fed by level-1 copies, level 3 by level-2 copies,\r\n and so on. This is the classic tier merge, and it is the mode you want when\r\n the design reads \"two of the same tier make one of the next\".\r\n\r\n Prefer it over `CheapestFirst` for merge economies. `CheapestFirst` is an\r\n *order*, not a restriction: once the cheap copies run out it will burn a\r\n leveled one, and it burns it **whole** (an instance cannot be partially\r\n consumed), so a copy worth `W(2) = 2` pays a cost of `1` and the remainder\r\n is destroyed. Under `SameLevelOnly` that copy is not a candidate at all —\r\n the upgrade is refused instead, with an error naming the level that is\r\n short. Overshoot is impossible whenever the weight curve is integral,\r\n because the cost of leaving level `L` is exactly `W(L)` — one copy per\r\n upgrade.\r\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\r\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\r\n validated individually (must exist, must be the same item/catalog, must not\r\n be equipped or expired, must not already be claimed elsewhere in the same\r\n batch) and rejected by name if any check fails. If the combined `W(level)`\r\n of your supplied instances doesn't cover the target, the whole upgrade is\r\n rejected — nothing is partially burned.\r\n\r\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\r\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\r\nthat mode the self-item cost is settled by the _regular_ resource-consume\r\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\r\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\r\nrather than being burned), and the rest come from plain inventory count, with\r\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\r\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\r\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\r\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\r\nself-item cost entry, or fodder that's a different item, is rejected.\r\n\r\n---\r\n\r\n## ItemMetadata\r\n\r\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\r\nsubsystem and general UI.\r\n\r\n```ts\r\ninterface ItemMetadata {\r\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\r\n CollectionID?: string; // ties the item into a Collection set/album page\r\n AuthorID?: string; // e.g. UGC/creator attribution\r\n}\r\n```\r\n\r\n---\r\n\r\n## NFTModel\r\n\r\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\r\nitem mirrored on both an EVM chain and Solana).\r\n\r\n```ts\r\ninterface NFTModel {\r\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\r\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\r\n}\r\n\r\ninterface NFTNetworkBinding {\r\n ContractAddress?: string; // EVM contract or Solana mint address\r\n TokenID?: string;\r\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\r\n}\r\n```\r\n\r\nSee the blockchain-system skill for the wallet/mint/transfer flows that\r\npopulate and consume this binding.\r\n\r\n---\r\n\r\n## Player state: InventoryV2\r\n\r\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\r\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch` (and other inventory-affecting calls).\r\n\r\n```ts\r\ninterface UserInventoryState {\r\n Version?: number;\r\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\r\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\r\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\r\n ConversionDaily?: Record<string, ConversionDailyCounter>;\r\n}\r\n\r\ninterface ItemTotals {\r\n StackableAmount: number;\r\n UnstackableAmount: number;\r\n TotalAmount: number;\r\n}\r\n\r\ninterface UnstackableItemInstanceState {\r\n ItemInstanceID: string;\r\n ItemID: string;\r\n CatalogID?: string | null;\r\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\r\n RemainingUses?: number; // consumable-with-charges items\r\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\r\n AcquiredAt: string;\r\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\r\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\r\n CustomData?: string | null;\r\n}\r\n```\r\n\r\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\r\ncurrency-system skill; they ride along in the same inventory snapshot but\r\naren't item-related.\r\n\r\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\r\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\r\nwhich half of the inventory backs it.\r\n\r\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\r\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\r\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\r\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\r\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\r\none copy — e.g. leveling one copy of a stack of five identical swords — the\r\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\r\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\r\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\r\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\r\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\r\ncome back as a **different** id than the one you called with — always read\r\nthe instance id off the response (or the refreshed cache), don't assume it's\r\nunchanged. The backend also opportunistically re-merges pristine fragments of\r\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\r\nyou don't need to do anything to trigger or handle that.\r\n\r\n---\r\n\r\n## Requests, responses, and actions\r\n\r\n```ts\r\ninterface ItemRequest extends BaseRequest {\r\n ItemInstanceID?: string;\r\n Levels?: number;\r\n TargetLevel?: number;\r\n FodderInstanceIDs?: string[];\r\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\r\n Upgrades?: ItemUpgradeRef[];\r\n}\r\n```\r\n\r\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\r\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\r\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\r\n**not** expose them — it only ever raises by one level per call. To move\r\nseveral levels in one call (on one or many instances), use\r\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\r\n\r\n```ts\r\ninterface ItemUpgradeRef {\r\n ItemInstanceID?: string;\r\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\r\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\r\n FodderInstanceIDs?: string[];\r\n}\r\n\r\ninterface FodderConsumedEntry {\r\n ItemInstanceID: string;\r\n Units: number; // how many copies of this instance/pack were burned\r\n Level: number; // the fodder instance's level at time of consumption\r\n}\r\n\r\ninterface UpgradeItemLevelResponse {\r\n ServerTimeUtc: string;\r\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\r\n ItemID: string;\r\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\r\n Level: number; // new level after the upgrade\r\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\r\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\r\n}\r\n\r\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\r\n```\r\n\r\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\r\nuseful when reading logs/errors that echo the action):\r\n\r\n```ts\r\nconst ItemAction = {\r\n UpgradeLevel: \"UpgradeLevel\",\r\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\r\n} as const;\r\n```\r\n\r\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\r\nshape used across the whole SDK — see the currency-system skill for the full\r\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\r\n`PremiumDiscounts` can reduce a displayed base cost.\r\n\r\n### Server-side limits (verified against the backend)\r\n\r\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\r\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\r\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\r\n appear in the result array at all. Chunk larger sets yourself.\r\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\r\n id in the same call only processes once.\r\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\r\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\r\n (single call fails outright; batch reports it as a failed item).\r\n- **Atomicity**: both the single and batch charge/patch happen inside one\r\n Mongo transaction — either the whole thing (cost + level + any fodder burns\r\n - owner Power recompute) applies, or none of it does.\r\n- **Idempotency**: the backend replays the same result for a repeated call\r\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\r\n server-side reason key, so re-running the _same target level_ twice is safe\r\n to retry). The TS `upgradeLevel` method, however, mints a fresh\r\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\r\n from the SDK's side, two separate calls are always two separate operations;\r\n see the Gotchas section in SKILL.md.\r\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Match data model — reference\r\n\r\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\r\nrequest/response types. All of these are **strictly typed in the SDK** —\r\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\r\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\r\n`@idosgames/core`, so `getDefinitions()` and\r\n`getSection<MatchDefinitions>(\"Match\")` give you concrete types, not\r\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\r\nlater still round-trips. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\nTerminology: the backend consistently calls the cost to participate **Entry**\r\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\r\nin the Match model. Use that vocabulary in any UI copy you generate.\r\n\r\n## Contents\r\n\r\n- [Player state](#player-state) — `UserMatchState`\r\n- [Match (offer)](#match-offer) — `PvPMatch`\r\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\r\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\r\n- [InstantBattleRule](#instantbattlerule)\r\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\r\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\r\n- [Net reward / burn formula](#net-reward--burn-formula)\r\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\r\n- [Request shape](#request-shape)\r\n\r\n---\r\n\r\n## Player state\r\n\r\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\r\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\r\nhydrates it at login.\r\n\r\n```ts\r\ninterface UserMatchState {\r\n PvPBattleStrategy?: BattleStepConfig[];\r\n CreationLimits?: UserMatchCreationLimitState | null;\r\n}\r\n\r\ninterface UserMatchCreationLimitState {\r\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\r\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\r\n DailyResetUtc?: string; // next UTC midnight reset\r\n}\r\n\r\ninterface BattleStepConfig {\r\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\r\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\r\n}\r\n```\r\n\r\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\r\nserver-side (`Match.CreationLimits` on the player document, written by\r\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\r\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\r\nfor a client to eventually show \"next match available in…\" UI, but nothing in\r\n`MatchService` currently reads it back into this cache slot — treat it as\r\ninformational/future until a response actually populates it for you.\r\n\r\n---\r\n\r\n## Match (offer)\r\n\r\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\r\nand `UpdateMatchResponse`.\r\n\r\n```ts\r\ninterface PvPMatch {\r\n MatchID: string;\r\n TitleID?: string;\r\n RuleID?: string;\r\n CreatedAt?: string;\r\n CreatorID?: string;\r\n CreatorCharacterID?: string;\r\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\r\n TargetUserID?: string; // set = private/targeted challenge; absent = public\r\n Entry?: ResourceBundle; // the creator's entry cost\r\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\r\n RefundCreationCostOnCancel?: boolean;\r\n JoinedByUserID?: string;\r\n JoinedByCharacterID?: string;\r\n JoinedAt?: string;\r\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\r\n WinnerUserID?: string; // absent/null on a draw\r\n CompletedAt?: string;\r\n IsRewardDistributed?: boolean;\r\n RewardDistributedAt?: string;\r\n}\r\n```\r\n\r\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\r\nmatch) are tracked separately — cancelling refunds the entry cost always, and\r\nthe creation fee only when `RefundCreationCostOnCancel` is true.\r\n\r\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\r\nresolves the battle synchronously in the same call, so a match goes directly\r\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\r\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\r\nenum for forward-compat / other match modes, not for instant-battle.\r\n\r\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\r\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\r\nmatch's strategy from `getMyMatches` or after you've fetched the match some\r\nother way; it isn't needed to join, since your own strategy is what you send\r\nto `instantBattle`.\r\n\r\n---\r\n\r\n## Battle result\r\n\r\nReturned inside `InstantBattleResponse.Battle`.\r\n\r\n```ts\r\ninterface BattleResult {\r\n WinnerUserID?: string; // absent on a draw\r\n LoserUserID?: string; // absent on a draw\r\n Entry?: ResourceBundle; // one side's entry cost that was in play\r\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\r\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\r\n IsDraw?: boolean;\r\n P1BattleProfile?: PlayerBattleProfile; // the match creator\r\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\r\n}\r\n\r\ninterface BattleLogEntry {\r\n RoundIndex?: number; // 1-based\r\n AttackerID?: string;\r\n DefenderID?: string;\r\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\r\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\r\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\r\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\r\n DefenderHpRemaining?: number; // floored at 0\r\n}\r\n\r\ninterface PlayerBattleProfile {\r\n UserID?: string;\r\n SelectedCharacterID?: string;\r\n SelectedCharacter?: CharacterModel; // see character-system skill\r\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\r\n Stats?: FighterStats; // final computed combat stats used for this fight\r\n}\r\n\r\ninterface FighterStats {\r\n MaxHp?: number; // starting HP, for a results-screen HP bar\r\n CurrentHp?: number; // HP at the end of the fight\r\n Damage?: number;\r\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\r\n CritChance?: number; // 0..MaxCritChance\r\n CritMultiplier?: number;\r\n Armor?: number; // flat damage reduction\r\n DodgeChance?: number; // 0..MaxDodgeChance\r\n}\r\n```\r\n\r\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\r\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\r\nhit drops the defender to 0 HP, the defender does not get to act that round.\r\n`AttackZone`/`DefenseZone` per log entry come from each side's\r\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\r\nstrategy shorter than the battle simply repeats from the top.\r\n\r\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\r\n\r\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\r\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\r\n3. `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\r\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\r\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\r\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\r\n\r\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\r\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\r\n\r\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\r\nlevel-scaling snapshot the engine used internally — the backend\r\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\r\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\r\nthat depends on it being populated.\r\n\r\n`FighterStats` is the resolved combat stats each fighter fought with — read\r\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\r\none fight, not a live/cached character stat.\r\n\r\n---\r\n\r\n## Config: MatchDefinitions\r\n\r\nReturned by `getDefinitions()`; cached via\r\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\r\n\r\n```ts\r\ninterface MatchDefinitions {\r\n InstantBattle?: InstantBattleDefinitions;\r\n}\r\n\r\ninterface InstantBattleDefinitions {\r\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\r\n Defaults?: InstantBattleSettings; // title-wide combat fallback\r\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\r\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\r\n}\r\n```\r\n\r\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\r\nthere's no other battle mode in the model today. If the title hasn't\r\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\r\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\r\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\r\n— so there is always at least one valid `RuleID` to pass.\r\n\r\nResolution order for every block is **rule's own → title `Defaults` (or\r\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\r\ninline-over-preset pattern the character module uses. `StatMapping` resolves\r\nper-field (each role can come from a different layer); `Combat`, `Entry`,\r\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\r\ntitle's `EntryDefaults`, even for fields it left unset).\r\n\r\n---\r\n\r\n## InstantBattleRule\r\n\r\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\r\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\r\n\r\n```ts\r\ninterface InstantBattleRule {\r\n RuleID?: string;\r\n DisplayName?: string;\r\n Description?: string;\r\n Economy?: MatchEconomySettings;\r\n Entry?: MatchEntrySettings;\r\n Creation?: MatchCreationSettings;\r\n Settings?: InstantBattleSettings;\r\n}\r\n\r\ninterface MatchEconomySettings {\r\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\r\n}\r\n```\r\n\r\n---\r\n\r\n## Combat formulas\r\n\r\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\r\nconfigures how a fighter's `FighterStats` are derived for a battle.\r\n\r\n```ts\r\ninterface InstantBattleSettings {\r\n StatMapping?: CombatStatMapping;\r\n Combat?: MatchCombatSettings;\r\n Formula?: MatchStatFormula;\r\n}\r\n\r\ninterface CombatStatMapping {\r\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\r\n DamageStatID?: string; // Default: \"Damage\"\r\n ArmorStatID?: string; // Default: \"Armor\"\r\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\r\n CritChanceStatID?: string; // Default: \"CritChance\"\r\n CritDamageStatID?: string; // Default: \"CritDamage\"\r\n DodgeStatID?: string; // Default: \"Speed\"\r\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\r\n}\r\n\r\ninterface MatchCombatSettings {\r\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\r\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\r\n MinHitDamage?: number; // floor for a hit after armor; default 1\r\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\r\n MaxCritChance?: number; // clamp; default 0.6\r\n MaxDodgeChance?: number; // clamp; default 0.4\r\n}\r\n\r\ninterface MatchStatFormula {\r\n Health?: FormulaSpec;\r\n Damage?: FormulaSpec;\r\n Armor?: FormulaSpec;\r\n AttackSpeed?: FormulaSpec;\r\n CritChance?: FormulaSpec;\r\n CritDamage?: FormulaSpec;\r\n Dodge?: FormulaSpec;\r\n}\r\n\r\ninterface FormulaSpec {\r\n Terms?: FormulaTerm[]; // the value = sum of terms\r\n}\r\n\r\ninterface FormulaTerm {\r\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\r\n Factors?: FormulaFactor[];\r\n}\r\n\r\ninterface FormulaFactor {\r\n Kind?: \"Constant\" | \"Variable\" | \"Curve\"; // default Constant\r\n Constant?: number; // Kind = Constant; empty = 1 (does not change the product)\r\n VariableID?: string; // Kind = Variable\r\n Argument?: string; // the variable's argument (a StatID, ...)\r\n Curve?: ScalarCurveSpec; // Kind = Curve, evaluated at the context's step\r\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\r\n}\r\n```\r\n\r\n⚠ `FormulaSpec` is a **platform** primitive and knows nothing about combat. The\r\nvocabulary of `VariableID` belongs to the MODULE; for instant battle it is\r\n`Stat`, `RankMultiplier`, `AllMight`, `GearFlat`, `GearPercent`, with `Argument`\r\ncarrying the `StatID` (an empty `Argument` on `Stat` means \"this role's own mapped\r\nstat\"). This replaced the old `FormulaSource` enum, which hard-coded those five\r\ncombat concepts inside the primitive.\r\n\r\n⚠ **An unknown `VariableID` means \"not computed\", not `0`.** A typo in the dashboard\r\ntherefore surfaces as \"my formula did not apply\" — visible and safe — rather than as a\r\nfighter silently walking into battle with 1 HP.\r\n\r\nA factor may carry a whole `ScalarCurveSpec` (`Kind: \"Curve\"`), but a curve can never\r\ncontain an expression. That is what makes the two layers acyclic by construction.\r\n\r\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\r\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\r\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\r\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\r\na concrete character `StatID` so the character's `StatLevels` (see\r\n`character-system` skill) feed into it — the same `StatID`s also key\r\nequipment flat/percent bonuses, so a remap automatically covers gear too.\r\nFactors reference base per-stat values and multipliers, never another role's\r\n_final_ value, so there are no formula cycles.\r\n\r\n**When a role has no custom formula** (`Formula` unset for that role), the\r\nengine falls back to its built-in default (backend\r\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\r\n\r\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\r\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\r\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight)\r\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\r\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\r\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\r\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\r\n\r\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\r\nscaling + character-rank scaling — see `character-system`\r\n`references/data-model.md`), `RankMultiplier` is the character's current\r\nrank's `RankStatCurve` value, `AllMight` is the raw (un-offset) AllMight\r\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\r\nequipped-item bonuses for that `StatID` (scaled by the item instance's\r\nupgrade level). **This is config for building previews/tooltips, not\r\nsomething to execute client-side to predict a battle outcome** — the server\r\nevaluates it; treat any client-side evaluation as an estimate only.\r\n\r\n---\r\n\r\n## Entry & creation settings\r\n\r\n```ts\r\ninterface MatchEntrySettings {\r\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\r\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\r\n AllowEventTokens?: boolean; // default false\r\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\r\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\r\n}\r\n\r\ninterface EntryResourceRule {\r\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\r\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\r\n CatalogID?: string; // when Kind === \"Item\"\r\n ItemID?: string; // when Kind === \"Item\"\r\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\r\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\r\n MinAmount?: number; // 0 = no lower bound\r\n MaxAmount?: number; // 0 = no upper bound\r\n}\r\n\r\ninterface MatchCreationSettings {\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\r\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\r\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\r\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\r\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\r\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\r\n}\r\n```\r\n\r\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\r\noffer currencies/items/event tokens the rule permits, and clamp the amount\r\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\r\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\r\nas an entry.\"` regardless of policy) — refunding/awarding would have to\r\nrecreate the item instance and lose its upgrade level. Duplicate positions\r\n(same currency, or same catalog+item, or same event-token address) submitted\r\nin one `Entry` are merged server-side before validation, so you don't need to\r\ndedupe client-side.\r\n\r\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\r\n— both can be charged on creation (merged into one `Consume.Standard` charge),\r\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\r\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\r\ncreation time) control only the creation fee on cancel; the entry cost itself\r\nis always refunded on a successful cancel. The creation fee is **always**\r\nsunk once a match is actually played (win, loss, or draw), regardless of the\r\nrefund flag. Don't assume what was refunded — read it off\r\n`CancelMatchResponse.Resources`, which reflects what the server actually\r\nreturned.\r\n\r\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\r\n`createMatch` when targeting a specific opponent, and the authoritative check\r\non `instantBattle` (both directions of the pair, UTC calendar day, counting\r\n`Completed` matches) — a private challenge can still be rejected at battle\r\ntime even if it passed at creation time if the pair played other matches in\r\nbetween.\r\n\r\n---\r\n\r\n## Net reward / burn formula\r\n\r\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\r\neach of the loser's-and-winner's-combined entry positions and burns a share\r\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\r\n/ `CalculateNetReward`):\r\n\r\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\r\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\r\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\r\n rule's `Economy` is unset.\r\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\r\n exactly — no burn (items are indivisible; burning progress-style event\r\n tokens would be meaningless).\r\n\r\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\r\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\r\n\r\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\r\n\r\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\r\n leaves their balance and joins the pool); creator (winner) has\r\n `Grant.Standard = NetReward` (their own entry was already committed at\r\n `createMatch`, so only the reward is granted now).\r\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\r\n was already spent at `createMatch`, nothing more to take); joiner (winner)\r\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\r\n time) **and** `Grant.Standard = NetReward` in the same operation.\r\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\r\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\r\n never paid anything, so there's nothing to refund on their side. The\r\n creation fee is not refunded on a draw (it's sunk once played, per above).\r\n\r\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\r\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\r\nlogic branches on which one is present (see `MatchService.instantBattle` in\r\nSKILL.md's Gotchas).\r\n\r\n---\r\n\r\n## Battle strategy resolution\r\n\r\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\r\nwhichever side's profile is being built) resolve the strategy to use with the\r\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\r\n\r\n1. The `battleStrategy` passed in that specific request, if non-empty.\r\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\r\n non-empty.\r\n3. Otherwise a **freshly randomized** 3-step strategy (random\r\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\r\n — not persisted).\r\n\r\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\r\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\r\n\r\n---\r\n\r\n## Request shape\r\n\r\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\r\ninternally — useful context for reading error messages, not something you\r\nconstruct by hand:\r\n\r\n```ts\r\ninterface MatchRequest extends BaseRequest {\r\n MatchID?: string;\r\n TargetUserID?: string;\r\n Entry?: ResourceBundle;\r\n BattleStrategy?: BattleStepConfig[];\r\n CharacterID?: string;\r\n RuleID?: string;\r\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\r\n Page?: number;\r\n PageSize?: number;\r\n Statuses?: string[]; // GetMyMatches filter\r\n OnlyPublic?: boolean; // GetAvailableMatches filter\r\n}\r\n```\r\n\r\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\r\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\r\ncorrelation — informational, not something you need to read or set yourself.\r\n"
|
|
8
|
+
"content": "# Match data model — reference\r\n\r\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\r\nrequest/response types. All of these are **strictly typed in the SDK** —\r\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\r\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\r\n`@idosgames/core`, so `getDefinitions()` and\r\n`getSection<MatchDefinitions>(\"Match\")` give you concrete types, not\r\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\r\nlater still round-trips. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\nTerminology: the backend consistently calls the cost to participate **Entry**\r\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\r\nin the Match model. Use that vocabulary in any UI copy you generate.\r\n\r\n## Contents\r\n\r\n- [Player state](#player-state) — `UserMatchState`\r\n- [Match (offer)](#match-offer) — `PvPMatch`\r\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\r\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\r\n- [InstantBattleRule](#instantbattlerule)\r\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\r\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\r\n- [Net reward / burn formula](#net-reward--burn-formula)\r\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\r\n- [Request shape](#request-shape)\r\n\r\n---\r\n\r\n## Player state\r\n\r\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\r\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\r\nhydrates it at login.\r\n\r\n```ts\r\ninterface UserMatchState {\r\n PvPBattleStrategy?: BattleStepConfig[];\r\n CreationLimits?: UserMatchCreationLimitState | null;\r\n}\r\n\r\ninterface UserMatchCreationLimitState {\r\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\r\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\r\n DailyResetUtc?: string; // next UTC midnight reset\r\n}\r\n\r\ninterface BattleStepConfig {\r\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\r\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\r\n}\r\n```\r\n\r\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\r\nserver-side (`Match.CreationLimits` on the player document, written by\r\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\r\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\r\nfor a client to eventually show \"next match available in…\" UI, but nothing in\r\n`MatchService` currently reads it back into this cache slot — treat it as\r\ninformational/future until a response actually populates it for you.\r\n\r\n---\r\n\r\n## Match (offer)\r\n\r\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\r\nand `UpdateMatchResponse`.\r\n\r\n```ts\r\ninterface PvPMatch {\r\n MatchID: string;\r\n TitleID?: string;\r\n RuleID?: string;\r\n CreatedAt?: string;\r\n CreatorID?: string;\r\n CreatorCharacterID?: string;\r\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\r\n TargetUserID?: string; // set = private/targeted challenge; absent = public\r\n Entry?: ResourceBundle; // the creator's entry cost\r\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\r\n RefundCreationCostOnCancel?: boolean;\r\n JoinedByUserID?: string;\r\n JoinedByCharacterID?: string;\r\n JoinedAt?: string;\r\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\r\n WinnerUserID?: string; // absent/null on a draw\r\n CompletedAt?: string;\r\n IsRewardDistributed?: boolean;\r\n RewardDistributedAt?: string;\r\n}\r\n```\r\n\r\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\r\nmatch) are tracked separately — cancelling refunds the entry cost always, and\r\nthe creation fee only when `RefundCreationCostOnCancel` is true.\r\n\r\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\r\nresolves the battle synchronously in the same call, so a match goes directly\r\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\r\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\r\nenum for forward-compat / other match modes, not for instant-battle.\r\n\r\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\r\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\r\nmatch's strategy from `getMyMatches` or after you've fetched the match some\r\nother way; it isn't needed to join, since your own strategy is what you send\r\nto `instantBattle`.\r\n\r\n---\r\n\r\n## Battle result\r\n\r\nReturned inside `InstantBattleResponse.Battle`.\r\n\r\n```ts\r\ninterface BattleResult {\r\n WinnerUserID?: string; // absent on a draw\r\n LoserUserID?: string; // absent on a draw\r\n Entry?: ResourceBundle; // one side's entry cost that was in play\r\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\r\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\r\n IsDraw?: boolean;\r\n P1BattleProfile?: PlayerBattleProfile; // the match creator\r\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\r\n}\r\n\r\ninterface BattleLogEntry {\r\n RoundIndex?: number; // 1-based\r\n AttackerID?: string;\r\n DefenderID?: string;\r\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\r\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\r\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\r\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\r\n DefenderHpRemaining?: number; // floored at 0\r\n}\r\n\r\ninterface PlayerBattleProfile {\r\n UserID?: string;\r\n SelectedCharacterID?: string;\r\n SelectedCharacter?: CharacterModel; // see character-system skill\r\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\r\n Stats?: FighterStats; // final computed combat stats used for this fight\r\n}\r\n\r\ninterface FighterStats {\r\n MaxHp?: number; // starting HP, for a results-screen HP bar\r\n CurrentHp?: number; // HP at the end of the fight\r\n Damage?: number;\r\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\r\n CritChance?: number; // 0..MaxCritChance\r\n CritMultiplier?: number;\r\n Armor?: number; // meaning depends on ArmorMode: damage units (Flat) or a 0..MaxArmorReduction fraction (PercentReduction)\r\n DodgeChance?: number; // 0..MaxDodgeChance\r\n}\r\n```\r\n\r\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\r\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\r\nhit drops the defender to 0 HP, the defender does not get to act that round.\r\n`AttackZone`/`DefenseZone` per log entry come from each side's\r\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\r\nstrategy shorter than the battle simply repeats from the top.\r\n\r\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\r\n\r\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\r\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\r\n3. Apply armor per `ArmorMode`:\r\n - `Flat` (default): `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\r\n - `PercentReduction`: `potentialDamage = max(MinHitDamage, rawDamage * (1 - defender.Armor))`,\r\n with `Armor` already clamped to `[0, MaxArmorReduction]` in step 7 of `CalculateStats`.\r\n The `MinHitDamage` floor applies in both modes, so armor can never heal the defender.\r\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\r\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\r\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\r\n\r\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\r\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\r\n\r\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\r\nlevel-scaling snapshot the engine used internally — the backend\r\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\r\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\r\nthat depends on it being populated.\r\n\r\n`FighterStats` is the resolved combat stats each fighter fought with — read\r\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\r\none fight, not a live/cached character stat.\r\n\r\n---\r\n\r\n## Config: MatchDefinitions\r\n\r\nReturned by `getDefinitions()`; cached via\r\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\r\n\r\n```ts\r\ninterface MatchDefinitions {\r\n InstantBattle?: InstantBattleDefinitions;\r\n}\r\n\r\ninterface InstantBattleDefinitions {\r\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\r\n Defaults?: InstantBattleSettings; // title-wide combat fallback\r\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\r\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\r\n}\r\n```\r\n\r\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\r\nthere's no other battle mode in the model today. If the title hasn't\r\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\r\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\r\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\r\n— so there is always at least one valid `RuleID` to pass.\r\n\r\nResolution order for every block is **rule's own → title `Defaults` (or\r\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\r\ninline-over-preset pattern the character module uses. `StatMapping` resolves\r\nper-field (each role can come from a different layer); `Combat`, `Entry`,\r\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\r\ntitle's `EntryDefaults`, even for fields it left unset).\r\n\r\n---\r\n\r\n## InstantBattleRule\r\n\r\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\r\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\r\n\r\n```ts\r\ninterface InstantBattleRule {\r\n RuleID?: string;\r\n DisplayName?: string;\r\n Description?: string;\r\n Economy?: MatchEconomySettings;\r\n Entry?: MatchEntrySettings;\r\n Creation?: MatchCreationSettings;\r\n Settings?: InstantBattleSettings;\r\n}\r\n\r\ninterface MatchEconomySettings {\r\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\r\n}\r\n```\r\n\r\n---\r\n\r\n## Combat formulas\r\n\r\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\r\nconfigures how a fighter's `FighterStats` are derived for a battle.\r\n\r\n```ts\r\ninterface InstantBattleSettings {\r\n StatMapping?: CombatStatMapping;\r\n Combat?: MatchCombatSettings;\r\n Formula?: MatchStatFormula;\r\n}\r\n\r\ninterface CombatStatMapping {\r\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\r\n DamageStatID?: string; // Default: \"Damage\"\r\n ArmorStatID?: string; // Default: \"Armor\"\r\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\r\n CritChanceStatID?: string; // Default: \"CritChance\"\r\n CritDamageStatID?: string; // Default: \"CritDamage\"\r\n DodgeStatID?: string; // Default: \"Speed\"\r\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\r\n}\r\n\r\ninterface MatchCombatSettings {\r\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\r\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\r\n MinHitDamage?: number; // floor for a hit after armor; default 1\r\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\r\n MaxCritChance?: number; // clamp; default 0.6\r\n MaxDodgeChance?: number; // clamp; default 0.4\r\n ArmorMode?: 'Flat' | 'PercentReduction'; // how armor reduces damage; default 'Flat'\r\n MaxArmorReduction?: number; // clamp in PercentReduction mode only (lower bound 0); default 0.9\r\n}\r\n\r\ninterface MatchStatFormula {\r\n Health?: FormulaSpec;\r\n Damage?: FormulaSpec;\r\n Armor?: FormulaSpec;\r\n AttackSpeed?: FormulaSpec;\r\n CritChance?: FormulaSpec;\r\n CritDamage?: FormulaSpec;\r\n Dodge?: FormulaSpec;\r\n}\r\n\r\ninterface FormulaSpec {\r\n Terms?: FormulaTerm[]; // the value = sum of terms\r\n}\r\n\r\ninterface FormulaTerm {\r\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\r\n Factors?: FormulaFactor[];\r\n}\r\n\r\ninterface FormulaFactor {\r\n Kind?: \"Constant\" | \"Variable\" | \"Curve\"; // default Constant\r\n Constant?: number; // Kind = Constant; empty = 1 (does not change the product)\r\n VariableID?: string; // Kind = Variable\r\n Argument?: string; // the variable's argument (a StatID, ...)\r\n Curve?: ScalarCurveSpec; // Kind = Curve, evaluated at the context's step\r\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\r\n}\r\n```\r\n\r\n⚠ `FormulaSpec` is a **platform** primitive and knows nothing about combat. The\r\nvocabulary of `VariableID` belongs to the MODULE; for instant battle it is\r\n`Stat`, `RankMultiplier`, `AllMight`, `GearFlat`, `GearPercent`, with `Argument`\r\ncarrying the `StatID` (an empty `Argument` on `Stat` means \"this role's own mapped\r\nstat\"). This replaced the old `FormulaSource` enum, which hard-coded those five\r\ncombat concepts inside the primitive.\r\n\r\n⚠ **An unknown `VariableID` means \"not computed\", not `0`.** A typo in the dashboard\r\ntherefore surfaces as \"my formula did not apply\" — visible and safe — rather than as a\r\nfighter silently walking into battle with 1 HP.\r\n\r\nA factor may carry a whole `ScalarCurveSpec` (`Kind: \"Curve\"`), but a curve can never\r\ncontain an expression. That is what makes the two layers acyclic by construction.\r\n\r\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\r\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\r\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\r\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\r\na concrete character `StatID` so the character's `StatLevels` (see\r\n`character-system` skill) feed into it — the same `StatID`s also key\r\nequipment flat/percent bonuses, so a remap automatically covers gear too.\r\nFactors reference base per-stat values and multipliers, never another role's\r\n_final_ value, so there are no formula cycles.\r\n\r\n**When a role has no custom formula** (`Formula` unset for that role), the\r\nengine falls back to its built-in default (backend\r\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\r\n\r\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\r\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\r\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight), then clamped to\r\n `[0, MaxArmorReduction]` when `ArmorMode` is `PercentReduction` (never clamped in `Flat`)\r\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\r\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\r\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\r\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\r\n\r\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\r\nscaling + character-rank scaling — see `character-system`\r\n`references/data-model.md`), `RankMultiplier` is the character's current\r\nrank's `RankStatCurve` value, `AllMight` is the raw (un-offset) AllMight\r\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\r\nequipped-item bonuses for that `StatID` (scaled by the item instance's\r\nupgrade level). **This is config for building previews/tooltips, not\r\nsomething to execute client-side to predict a battle outcome** — the server\r\nevaluates it; treat any client-side evaluation as an estimate only.\r\n\r\n---\r\n\r\n## Entry & creation settings\r\n\r\n```ts\r\ninterface MatchEntrySettings {\r\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\r\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\r\n AllowEventTokens?: boolean; // default false\r\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\r\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\r\n}\r\n\r\ninterface EntryResourceRule {\r\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\r\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\r\n CatalogID?: string; // when Kind === \"Item\"\r\n ItemID?: string; // when Kind === \"Item\"\r\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\r\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\r\n MinAmount?: number; // 0 = no lower bound\r\n MaxAmount?: number; // 0 = no upper bound\r\n}\r\n\r\ninterface MatchCreationSettings {\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\r\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\r\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\r\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\r\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\r\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\r\n}\r\n```\r\n\r\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\r\noffer currencies/items/event tokens the rule permits, and clamp the amount\r\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\r\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\r\nas an entry.\"` regardless of policy) — refunding/awarding would have to\r\nrecreate the item instance and lose its upgrade level. Duplicate positions\r\n(same currency, or same catalog+item, or same event-token address) submitted\r\nin one `Entry` are merged server-side before validation, so you don't need to\r\ndedupe client-side.\r\n\r\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\r\n— both can be charged on creation (merged into one `Consume.Standard` charge),\r\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\r\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\r\ncreation time) control only the creation fee on cancel; the entry cost itself\r\nis always refunded on a successful cancel. The creation fee is **always**\r\nsunk once a match is actually played (win, loss, or draw), regardless of the\r\nrefund flag. Don't assume what was refunded — read it off\r\n`CancelMatchResponse.Resources`, which reflects what the server actually\r\nreturned.\r\n\r\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\r\n`createMatch` when targeting a specific opponent, and the authoritative check\r\non `instantBattle` (both directions of the pair, UTC calendar day, counting\r\n`Completed` matches) — a private challenge can still be rejected at battle\r\ntime even if it passed at creation time if the pair played other matches in\r\nbetween.\r\n\r\n---\r\n\r\n## Net reward / burn formula\r\n\r\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\r\neach of the loser's-and-winner's-combined entry positions and burns a share\r\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\r\n/ `CalculateNetReward`):\r\n\r\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\r\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\r\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\r\n rule's `Economy` is unset.\r\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\r\n exactly — no burn (items are indivisible; burning progress-style event\r\n tokens would be meaningless).\r\n\r\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\r\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\r\n\r\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\r\n\r\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\r\n leaves their balance and joins the pool); creator (winner) has\r\n `Grant.Standard = NetReward` (their own entry was already committed at\r\n `createMatch`, so only the reward is granted now).\r\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\r\n was already spent at `createMatch`, nothing more to take); joiner (winner)\r\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\r\n time) **and** `Grant.Standard = NetReward` in the same operation.\r\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\r\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\r\n never paid anything, so there's nothing to refund on their side. The\r\n creation fee is not refunded on a draw (it's sunk once played, per above).\r\n\r\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\r\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\r\nlogic branches on which one is present (see `MatchService.instantBattle` in\r\nSKILL.md's Gotchas).\r\n\r\n---\r\n\r\n## Battle strategy resolution\r\n\r\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\r\nwhichever side's profile is being built) resolve the strategy to use with the\r\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\r\n\r\n1. The `battleStrategy` passed in that specific request, if non-empty.\r\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\r\n non-empty.\r\n3. Otherwise a **freshly randomized** 3-step strategy (random\r\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\r\n — not persisted).\r\n\r\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\r\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\r\n\r\n---\r\n\r\n## Request shape\r\n\r\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\r\ninternally — useful context for reading error messages, not something you\r\nconstruct by hand:\r\n\r\n```ts\r\ninterface MatchRequest extends BaseRequest {\r\n MatchID?: string;\r\n TargetUserID?: string;\r\n Entry?: ResourceBundle;\r\n BattleStrategy?: BattleStepConfig[];\r\n CharacterID?: string;\r\n RuleID?: string;\r\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\r\n Page?: number;\r\n PageSize?: number;\r\n Statuses?: string[]; // GetMyMatches filter\r\n OnlyPublic?: boolean; // GetAvailableMatches filter\r\n}\r\n```\r\n\r\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\r\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\r\ncorrelation — informational, not something you need to read or set yourself.\r\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -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 (who they're subscribed to, follower count, claimed invite rewards), activate someone else's referral 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.",
|
|
4
|
-
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount` and `FollowerIDs`, and which `InviteRewards` they've\n claimed (`InviteRewardStates`). Fetched with `getUserState()`.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a deterministic `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount`/`FollowerIDs` are decremented/pulled, the new\n one incremented, and `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.FollowerIDs;\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
4
|
+
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a deterministic `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount` 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### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Referral data model — reference\r\n\r\nFull shape of the config (`ReferralDefinitions`) and player state\r\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\r\nshared Core/Milestone progression-multiplier math that scales\r\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\r\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\r\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\r\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\r\nbackend adds later still round-trips. Field names are PascalCase (straight\r\nfrom the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\r\n- [SpendRewardDefinition](#spendrewarddefinition)\r\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\r\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\r\n- [Activation flow — server rules](#activation-flow--server-rules)\r\n- [Claim flow — server rules](#claim-flow--server-rules)\r\n\r\n---\r\n\r\n## Config: ReferralDefinitions\r\n\r\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\r\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\r\n\r\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\r\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\r\n\r\n```ts\r\ninterface ReferralDefinitions {\r\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\r\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\r\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\r\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\r\n}\r\n```\r\n\r\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\r\nthe same shared type used across every module (currencies, items, event\r\ntokens, premium-tier bundles). See the `currency-system` skill for its full\r\nshape if you need it.\r\n\r\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\r\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\r\n\r\n```ts\r\ninterface MilestoneDefinition {\r\n MilestoneID?: string;\r\n DisplayName?: string;\r\n AssetPaths?: Record<string, string>;\r\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\r\n Rewards?: ResourceGrant; // base payout\r\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\r\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\r\n SortOrder?: number;\r\n IsFeatured?: boolean;\r\n}\r\n```\r\n\r\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\r\n`BonusActive`/`SeasonChainID` context (see\r\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\r\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\r\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\r\nthough the type carries them for other modules.\r\n\r\n---\r\n\r\n## SpendRewardDefinition\r\n\r\n```ts\r\ninterface SpendRewardDefinition {\r\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\r\n IsEnabled?: boolean; // default true\r\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\r\n SourceCurrencyID?: string; // currency the follower spends\r\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\r\n}\r\n```\r\n\r\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\r\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\r\nsupposed to call after a successful deduction to compute and grant the\r\nkickback — but grepping the entire backend turns up **zero** definitions or\r\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\r\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\r\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\r\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\r\nevent tied to a follower's purchase — there is nothing to listen for.\r\n\r\n---\r\n\r\n## Player state: UserReferralState\r\n\r\nReturned by `getUserState()` as `{ Referral }`; cached at\r\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\r\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\r\n\r\n```ts\r\ninterface UserReferralState {\r\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\r\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\r\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\r\n FollowerIDs?: string[]; // UserIDs of those followers — kept in sync so a follower switching away can be pulled out correctly\r\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\r\n UpdatedAt?: string; // ISO timestamp of last change\r\n}\r\n\r\ninterface ReferralInviteRewardState {\r\n RewardID?: string; // == the MilestoneID key\r\n IsClaimed?: boolean;\r\n ClaimedAt?: string | null;\r\n}\r\n```\r\n\r\n`InviteRewardStates` only contains entries that have actually been claimed —\r\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\r\nmilestone systems); a milestone id absent from the map simply means \"not yet\r\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\r\n`RequiredProgress`.\r\n\r\nA player's referral code **is their own `UserID`** — the module has no\r\nseparate generated/short code. To let a player share \"their\" code, show them\r\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\r\nor endpoint for a display-friendly code.\r\n\r\n---\r\n\r\n## Invite-reward payout — the Milestone resolver\r\n\r\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\r\nverbatim. The backend runs it through the shared\r\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\r\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\r\ncontext (`Referral.cs`, `ClaimInviteReward`):\r\n\r\n```csharp\r\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\r\n{\r\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\r\n Player = doc,\r\n NowUtc = DateTime.UtcNow,\r\n});\r\n```\r\n\r\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\r\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\r\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\r\nbranches are always skipped for Referral. The **only** overlay that can ever\r\nchange an invite-reward payout is the title-wide progression multiplier:\r\n\r\n1. Read the title's `RewardProgressionMultiplierSpec` from\r\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\r\n also read — configured once per title, not per-module).\r\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\r\n scaling.\r\n3. Otherwise (`RewardProgressionResolver.cs`):\r\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\r\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\r\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\r\n **not** `FollowersCount` — the multiplier's progression axis is\r\n independent of the referral threshold you're claiming against.\r\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\r\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\r\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\r\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\r\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\r\n point the curve is the **identity**, so a player who has not reached the first\r\n tier gets no bonus.\r\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\r\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\r\n real zero. `NaN`/`Infinity` collapses to `1.0`.\r\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\r\n the resolver: a reward multiplier never reduces a reward unless the publisher\r\n says so explicitly.\r\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\r\n `null`, the grant is returned unscaled.\r\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\r\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\r\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\r\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\r\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\r\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\r\n left alone — they're applied later, after scaling, inside\r\n `ResourceService`.\r\n - **Rounding**: each scaled amount goes through the platform-wide\r\n `ModifierService.Apply` with a `Multiply` step, which finishes with\r\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\r\nmultiplier)`, never negative, never silently truncated down.\r\n\r\nTo preview this on the client before the player claims, call\r\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\r\nthe exact same spec/progress/rounding server-side and returns\r\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\r\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\r\nown copy of this multiplier — it's title-wide, not per-module.\r\n\r\n---\r\n\r\n## Activation flow — server rules\r\n\r\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\r\nin order:\r\n\r\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\r\n the SDK side before this is even sent).\r\n2. Trimmed + uppercased. If it equals the caller's own `UserID` (also\r\n uppercased): `\"Cannot activate your own referral code\"`.\r\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\r\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\r\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\r\n6. If the caller is already subscribed to that **same** code:\r\n `\"Referral code already activated\"`.\r\n7. Otherwise the call **succeeds**, whether or not the player had a previous\r\n referrer:\r\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\r\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\r\n the caller's id is pulled from their `FollowerIDs`.\r\n - The new referrer's `FollowersCount` is atomically incremented and the\r\n caller's id added to `FollowerIDs` (`$addToSet`, so re-adding is a\r\n no-op).\r\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\r\n two as mutual friends (capped by the Social module's friend limit;\r\n silently skipped if either side is already at the cap).\r\n - `IsFirstActivation` is `true` only when the caller had **no** previous\r\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\r\n When true, `ActivationReward` is granted via\r\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\r\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\r\n set permanently — a later referrer switch will not re-grant it.\r\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\r\n guarding against a concurrent change (matches \"no previous referrer\" or\r\n \"still the previously-read referrer\"); if that races, the call fails\r\n with `\"Referral state was modified concurrently. Please retry.\"` and the\r\n client should just retry.\r\n\r\n## Claim flow — server rules\r\n\r\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\r\norder:\r\n\r\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\r\n2. Must exist in `config.Referral.InviteRewards`, else\r\n `\"Invite reward '{id}' not found in configuration\"`.\r\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\r\n `\"Not enough followers. Required: {n}, current: {m}\"`.\r\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\r\n5. The resolved grant (see above) is applied atomically with idempotency key\r\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\r\n only allows the write when there's no existing claimed state for that\r\n reward id (protects against a double-claim race the same way step 3/4\r\n protect against a stale read).\r\n"
|
|
8
|
+
"content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n}\n```\n\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\nsupposed to call after a successful deduction to compute and grant the\nkickback — but grepping the entire backend turns up **zero** definitions or\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\nevent tied to a follower's purchase — there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\n // ⚠ FollowerIDs 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\nA player's referral code **is their own `UserID`** — the module has no\nseparate generated/short code. To let a player share \"their\" code, show them\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\nor endpoint for a display-friendly code.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`): `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. Trimmed + uppercased. If it equals the caller's own `UserID` (also\n uppercased): `\"Cannot activate your own referral code\"`.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n (`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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-system",
|
|
3
3
|
"description": "Build a friends / social system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.social (SocialService): load the friends list, incoming friend requests, and recommended friends, send/accept/decline friend requests, remove a friend, and read the social activity timeline (attacks, raids, friend-adds). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a friends list screen, friend-request inbox, add-friend flow, recommended friends / player search, or an activity feed, or otherwise touches client.social, SocialService, SocialModels, FriendPublicProfile, or the social timeline — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## The three lists + the feed\n\nPlayer social state (`UserSocialState`) has four independent arrays, all\nstring `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Only grown client-side by `sendFriendRequest`; there\n is no `getOutgoingRequests()` — track it from the cache after you send.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst social = client.social; // the SocialService\n```\n\nEvery social method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\nThe response is **self-sufficient**: `Counters` carries the sizes of YOUR lists\nafter the operation (`FriendsCount`, `IncomingRequestsCount`,\n`OutgoingRequestsCount`), and `Target` carries the other side's public profile\nwhere the UI needs it right now — sending and accepting a request. Apply your\nown edit locally and reconcile against `Counters`; do not re-issue\n`getFriendsList()` just to redraw. `Target` is absent for decline/remove: the\nentry disappears from the list anyway, so the server does not read the profile.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\nsocial?.Accepted; // string[] of friend UserIDs\nsocial?.IncomingRequests; // string[] awaiting your accept/decline\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved\nsocial?.Timeline; // SocialTimelineEvent[]\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// There's no polling endpoint for the other side's decision — re-check via\n// getFriendsList()/getIncomingRequests() (e.g. on next screen focus) to see\n// if it was accepted (moves to Accepted) or the outgoing entry disappears.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
|
|
4
|
+
"content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## Counters (server) vs the three lists + the feed (local cache)\n\n⚠ **`UserSocialState` has two halves, and they come from different places.**\n\nThe **counters** — `FriendsCount`, `IncomingCount`, `OutgoingCount` — are what\nthe server actually sends inside player state, and they are correct the moment\nthe player logs in. Friendships and requests themselves live in their own edge\ncollection: they used to be three arrays inside the player document, which\nmeant whoever sent you a request grew *your* document, without a ceiling, and\nit was re-read on every one of *your* calls.\n\nThe **four arrays below are a local SDK cache**, not server state. Nothing\nfills them on login — each is filled by its own call, and `OutgoingRequests`\nonly ever by your own sends. They are lost on restart, because nothing\nre-sends them.\n\nPlan the UI around that: **badges and counts come from the counters, lists only\nfrom a screen that loads them.** A friends-count badge needs no call; a friends\nlist screen must call `getFriendsList()` or it renders empty for a player who\nhas friends.\n\nThe four arrays, all string `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Grown client-side by `sendFriendRequest`, and loaded\n from the server by `getOutgoingRequests()`. **Call it on any screen that\n offers \"Add friend\"**: without it the list only knows about sends made in\n *this* run, so after a restart (or on a second device) a player who already\n asked someone is offered \"Add\" again. An accepted request leaves this list\n and appears in `Accepted`.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst social = client.social; // the SocialService\n```\n\nEvery social method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getOutgoingRequests()` | Load pending requests this player sent. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\nThe response is **self-sufficient**: `Counters` carries the sizes of YOUR lists\nafter the operation (`FriendsCount`, `IncomingRequestsCount`,\n`OutgoingRequestsCount`), and `Target` carries the other side's public profile\nwhere the UI needs it right now — sending and accepting a request. Apply your\nown edit locally and reconcile against `Counters`; do not re-issue\n`getFriendsList()` just to redraw. `Target` is absent for decline/remove: the\nentry disappears from the list anyway, so the server does not read the profile.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\n\n// Server-sent, correct immediately after login — use these for badges/counts.\nsocial?.FriendsCount; // number\nsocial?.IncomingCount; // number — e.g. the red dot on the friends tab\nsocial?.OutgoingCount; // number\n\n// Local cache — EMPTY until the matching call below has run at least once.\nsocial?.Accepted; // string[] of friend UserIDs — getFriendsList()\nsocial?.IncomingRequests; // string[] awaiting your accept/decline — getIncomingRequests()\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved — getOutgoingRequests()\nsocial?.Timeline; // SocialTimelineEvent[] — getTimeline()\n```\n\n⚠ Do not derive a count by taking `.length` of one of those arrays: before the\nmatching call has run they are empty, even for a player who has friends and\npending requests. That is exactly what the counters are for.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:outgoingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// That entry is local to this run — call getOutgoingRequests() when the screen\n// opens so a restarted app still shows \"Pending\" instead of \"Add\".\n// There's no push for the other side's decision — re-check via\n// getFriendsList()/getOutgoingRequests() (e.g. on next screen focus): an\n// accepted request moves to Accepted and leaves OutgoingRequests.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|