@idosgames/mcp 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Marketplace data model — reference\n\nFull shape of the config (`MarketplaceDefinitions`), the offer/state views the\nclient actually sees, the request shape, and the escrow/settlement/commission\nmodel in detail. All of these are **strictly typed in the SDK** —\n`MarketplaceDefinitions` and every nested block, `MarketplaceOfferView`,\n`MarketplaceAuctionState`, the various response types — are exported from\n`@idosgames/core`, so `getDefinitions()` /\n`getSection<MarketplaceDefinitions>(\"Marketplace\")` and every service call give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\n## Contents\n\n- [What the client never sees](#what-the-client-never-sees)\n- [Config: MarketplaceDefinitions](#config-marketplacedefinitions)\n- [Per-listing-type settings](#per-listing-type-settings)\n- [Offer view: MarketplaceOfferView](#offer-view-marketplaceofferview)\n- [Auction state](#auction-state)\n- [Auction bid-step formula](#auction-bid-step-formula)\n- [User state: UserMarketplaceState (rate limits)](#user-state-usermarketplacestate-rate-limits)\n- [Responses](#responses)\n- [Request shape](#request-shape)\n- [Settlement & escrow model](#settlement--escrow-model)\n- [Commission model](#commission-model)\n- [Server-side limits, locking, and idempotency](#server-side-limits-locking-and-idempotency)\n\n---\n\n## What the client never sees\n\nThe backend's internal `MarketplaceOfferDocument` (raw escrow bookkeeping,\ncommission snapshot) and the `MarketplaceTradeLogDocument` /\n`MarketplaceLedgerDocument` never reach the client. Everything documented here\nis the **public projection**: `MarketplaceOfferView` (what browsing/my-state/\nhistory return instead of the raw document) and `MarketplaceHistoryEntryView`.\nOne exception: `MarketplaceAuctionState` is shared **verbatim** between the\ninternal document and the public view — the backend assigns the same object to\nboth (`MarketplaceHelpers.BuildOfferView`, `Auction = doc.Auction`) — so what's\nbelow is exactly what's stored.\n\n---\n\n## Config: MarketplaceDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MarketplaceDefinitions>(\"Marketplace\")`.\n\n```ts\ninterface MarketplaceDefinitions {\n Enabled?: boolean; // master switch; default false — must be enabled explicitly\n Gate?: SegmentGate; // segment-based access gate (see other modules' Gate usage)\n Schedule?: ScheduleSpec; // time-window the marketplace is open; null = always open\n PricePolicy?: MarketplacePricePolicy;\n Commission?: MarketplaceCommissionPolicy;\n Tradability?: MarketplaceTradabilityPolicy;\n Listings?: MarketplaceListingSettings;\n Auctions?: MarketplaceAuctionSettings;\n BuyOrders?: MarketplaceBuyOrderSettings;\n DirectTrades?: MarketplaceDirectTradeSettings;\n}\n```\n\n`getDefinitions()`'s response also carries two computed flags alongside\n`Definitions`:\n\n```ts\ninterface MarketplaceGetDefinitionsResponse {\n Definitions?: MarketplaceDefinitions | null;\n IsOpenNow?: boolean | null; // Enabled && Schedule evaluated against server time\n GatePassed?: boolean | null; // Gate evaluated against this player's segment\n}\n```\n\nCheck both before showing \"create offer\" UI — `Enabled` alone doesn't account\nfor the schedule window or the segment gate. **`Gate`/`Schedule` are only\nenforced on trading actions** (create/buy/bid/fill/accept) — `claimBack`,\ncancels, and a decline are never blocked by either, confirmed in\n`MarketplaceV2.CheckTradeGate`'s call sites (`Marketplace.cs`): it's invoked\nfrom `CreateSellOffer`, `Buy`, `PlaceBid`, `CreateBuyOrder`, `FillBuyOrder`,\n`CreateDirectTrade`, and the accept branch of `RespondDirectTrade` — never\nfrom `CancelOffer`, `ClaimBack`, or the decline branch.\n\n`SegmentGate`, `ScheduleSpec`, and `LimitSpec` (used throughout the per-type\nsettings below) are generic cross-module shapes shared with other live-ops\nsystems (segment/schedule gating, anti-farm counters) — this reference only\nlists the fields Marketplace itself reads.\n\n---\n\n## Per-listing-type settings\n\n### PricePolicy — what a price/bid can be made of\n\n```ts\ninterface MarketplacePricePolicy {\n AllowVirtualCurrency?: boolean; // default true; ignored when Allowed is non-empty\n AllowItems?: boolean; // default false; ignored when Allowed is non-empty\n AllowEventTokens?: boolean; // default false; ignored when Allowed is non-empty\n Allowed?: EntryResourceRule[]; // authoritative whitelist when non-empty (kind + id + min/max amount)\n MaxPositions?: number; // default 5; cap on distinct resource entries + event tokens in one price; 0 = unlimited\n}\n```\n\nMirrors the entry-policy pattern from the Match module (`MatchEntrySettings`) —\nsame `EntryResourceRule` shape (`Kind`, `CurrencyID`/`CatalogID`/`ItemID`,\n`TokenType`/`EntityID`, `MinAmount`/`MaxAmount`). A `createListing`/\n`createAuction`/`createBuyOrder`/`createDirectTrade`'s price (or an auction's\nstarting-bid axis) is validated against this policy on create\n(`MarketplaceHelpers.ValidatePricePolicy`, `Marketplace.cs`):\n\nTwo rules apply **unconditionally**, regardless of whitelist/flags\n(`MarketplaceHelpers.cs`):\n\n- A virtual-currency price position is rejected unless that currency's own\n `VirtualCurrencyDefinition.Permissions.IsTradable === true` — error:\n `\"Currency '{id}' is not tradable between players (Permissions.IsTradable=false).\"`\n- An **unstackable item can never be used as a price position** (only as\n goods) — error: `\"Unstackable items cannot be used as a price.\"` — because\n granting it back would have to re-mint a new instance identity.\n\nWhen `Allowed` is non-empty it is authoritative — every price position must\nmatch one of its rules (including `MinAmount`/`MaxAmount`); the\n`AllowVirtualCurrency`/`AllowItems`/`AllowEventTokens` flags are only consulted\nwhen `Allowed` is empty.\n\n### Commission — the cut taken at settlement\n\n```ts\ninterface MarketplaceCommissionPolicy {\n Percent?: number; // default 0.05 (5%)\n MinPerPosition?: number; // floor per priced resource entry; default 0 (no minimum)\n Sink?: \"Burn\" | \"Ledger\"; // default \"Burn\"\n LedgerAccountID?: string; // target account when Sink === \"Ledger\"; empty falls back to \"default\"\n PerCatalogOverrides?: Record<string, MarketplaceCommissionOverride>;\n ApplyToDirectTrades?: boolean; // default false — gifts/trades are commission-exempt by default\n}\n\ninterface MarketplaceCommissionOverride {\n Percent?: number;\n MinPerPosition?: number;\n}\n```\n\n`PerCatalogOverrides` is keyed by the **goods'** `CatalogID` (not the price's) —\na catalog present here replaces the top-level `Percent`/`MinPerPosition` for\noffers whose goods come from that catalog\n(`MarketplaceHelpers.ResolveCommission`). See\n[Commission model](#commission-model) below for the exact per-position fee\nformula.\n\n### Tradability — what's allowed on the market at all\n\n```ts\ninterface MarketplaceTradabilityPolicy {\n AllowedCatalogIDs?: string[]; // null/absent = no catalog allow-list (all allowed unless denied)\n DeniedCatalogIDs?: string[];\n DeniedItemIDs?: string[];\n AllowUnstackableWithState?: boolean; // default true; allow listing instances with state (Level>1, RemainingUses>1, CustomData)\n}\n```\n\nBase requirement, checked first regardless of policy:\n`ItemDefinition.IsTradable` must be `true`\n(`MarketplaceHelpers.ResolveTradableItem`) — error:\n`\"Item '{itemID}' is not tradable (ItemDefinition.IsTradable=false).\"` The item\nis resolved with the same strict→fallback catalog resolver used elsewhere\n(self-heals `CatalogID` if the item moved catalogs), so the response's\n`GoodsCatalogID` can differ from what you originally passed as `catalogID`.\n\nWhen `AllowUnstackableWithState` is `false`, only \"pristine\" instances\n(`Level <= 1`, `RemainingUses <= 1`, no `CustomData`) may be escrowed as goods\n— error: `\"Instance '{id}' has state (level/custom data) — selling stateful\ninstances is disabled.\"` Equipped or expired instances are always rejected\nregardless of this flag (`\"Instance '{id}' is equipped — unequip it\nfirst.\"` / `\"Instance '{id}' has expired.\"`).\n\n### Listings\n\n```ts\ninterface MarketplaceListingSettings {\n Enabled?: boolean; // default true (when the module itself is enabled)\n AllowedDurationsHours?: number[]; // empty/null = default {24, 72, 168}\n MaxActiveListings?: number; // default 10; per-player cap counting Listing+Auction together; 0 = unlimited\n CreateLimits?: LimitSpec;\n BuyLimits?: LimitSpec;\n ListingFee?: ResourceConsume; // charged in addition to escrowing the goods; only the Standard part is used\n RefundListingFeeOnCancel?: boolean; // default false — fee is forfeit on cancel; never refunded on expiry either way\n}\n```\n\n`MaxActiveListings` is checked against a single count query across **both**\n`Listing` and `Auction` offer types created by the player\n(`MarketplaceDBService.CountActiveByCreatorAsync(..., Listing, Auction)`) — a\ntitle with `MaxActiveListings: 10` caps the player at 10 combined open\nlistings+auctions, not 10 of each.\n\n### Auctions\n\n```ts\ninterface MarketplaceAuctionSettings {\n Enabled?: boolean; // default true\n MinDurationHours?: number; // default 1\n MaxDurationHours?: number; // default 168\n AntiSnipeWindowSeconds?: number; // default 120; 0 = disabled\n AntiSnipeExtensionSeconds?: number; // default 120\n MaxAntiSnipeExtensions?: number; // default 10\n MinBidStepPercent?: number; // default 0.05 (5%)\n MinBidStepAbsolute?: number; // default 1\n BidLimits?: LimitSpec;\n}\n```\n\nAnti-snipe: a bid placed within `AntiSnipeWindowSeconds` of the auction's\ncurrent `ExpiresAt` pushes the deadline out by `AntiSnipeExtensionSeconds`,\nup to `MaxAntiSnipeExtensions` times total per auction —\n`MarketplaceAuctionState.ExtensionCount` tracks how many have already\nhappened (`Marketplace.cs`, `PlaceBid`). See\n[Auction bid-step formula](#auction-bid-step-formula) for the minimum-bid math.\n\n### Buy orders\n\n```ts\ninterface MarketplaceBuyOrderSettings {\n Enabled?: boolean; // default true\n MaxActiveOrders?: number; // default 5; per-player cap; 0 = unlimited\n AllowedDurationsHours?: number[]; // empty/null = default {24, 72, 168}\n CreateLimits?: LimitSpec;\n FillLimits?: LimitSpec;\n}\n```\n\n### Direct trades\n\n```ts\ninterface MarketplaceDirectTradeSettings {\n Enabled?: boolean; // default true\n OfferExpirationHours?: number; // default 168\n MaxPendingOutgoing?: number; // default 10; caps my own not-yet-responded-to outgoing trades; 0 = unlimited\n AllowGifts?: boolean; // default true; false = requestedBundle becomes mandatory\n CreateLimits?: LimitSpec;\n}\n```\n\n---\n\n## Offer view: MarketplaceOfferView\n\nWhat browsing, my-state, and single-offer reads return — the public\nprojection of an offer, regardless of type:\n\n```ts\ninterface MarketplaceOfferView {\n OfferID?: string;\n OfferType?: MarketOfferType; // \"Listing\" | \"Auction\" | \"BuyOrder\" | \"DirectTrade\"\n Status?: MarketOfferStatus; // \"Active\" | \"Completed\" | \"Cancelled\" | \"Declined\" | \"Expired\"\n CreatorUserID?: string;\n CreatorPublicData?: UserPublicDataModel; // display name/avatar for storefront rendering\n TargetUserID?: string; // set only for DirectTrade\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n GoodsAmount?: number;\n GoodsInstances?: UnstackableItemInstanceState[]; // populated when the goods are specific unstackable instances\n Price?: ResourceBundle; // Listing/BuyOrder asking price, or DirectTrade's requestedBundle\n Auction?: MarketplaceAuctionState; // populated only for OfferType === \"Auction\"\n CreatedAt?: string; // ISO timestamp\n ExpiresAt?: string;\n}\n```\n\n`Status: \"Expired\"` is set **lazily** on the read path itself\n(`MarketplaceHelpers.BuildOfferView`): an `Active` document whose `ExpiresAt`\nhas passed is projected as `\"Expired\"` in the view without writing anything to\nthe database — the authoritative DB flip only happens when the offer is\nactually claimed/finalized. Don't assume an offer past its `ExpiresAt` has\nalready flipped in storage just because a read showed `\"Expired\"`.\n\n`GoodsInstances` is `null` for `BuyOrder` offers even if the eventual goods\nwill be unstackable (a buy order names an `ItemID`/`CatalogID`, not specific\ninstances — the filler supplies fresh ones). For the other three offer types\nit's populated when the goods are unstackable, using the same\n`UnstackableItemInstanceState` shape as `InventoryV2.UnstackableItems` (see the\nitem-system skill) — `ItemInstanceID`, `Level`, `ExpiresAt`, `EquippedSlot`\n(always `null` in escrow), etc.\n\n---\n\n## Auction state\n\n```ts\ninterface MarketplaceAuctionState {\n BidAxisType?: ResourceEntryType; // \"VirtualCurrency\" | \"Item\" — see currency-system skill\n BidCurrencyID?: string; // set when BidAxisType is a currency\n BidCatalogID?: string; // set when BidAxisType is an item\n BidItemID?: string;\n StartingBid?: number;\n CurrentBid?: number; // 0 = no bids yet\n CurrentBidderID?: string;\n BidCount?: number;\n ExtensionCount?: number; // anti-snipe extensions used so far\n PendingRefunds?: MarketplaceBidRefund[]; // outbid players awaiting refund\n}\n\ninterface MarketplaceBidRefund {\n BidIndex?: number;\n UserID?: string;\n Amount?: number;\n Settled?: boolean; // false = still owed; drained by claimBack, getMyState, or a later bid/claim on the same offer\n SettledAt?: string;\n}\n```\n\nBidding uses a **single comparable axis** — exactly one VC or one stackable\nitem type — so bids can be strictly ordered. `PendingRefunds` is the outbox of\nplayers who were outbid: `Settled: false` entries are what `claimBack()` (no\n`offerID`) drains across all of a player's auctions in one call. The backend\nalso opportunistically drains refunds on `getMyState()` (for the calling\nplayer's own refunds) and after any `placeBid`/`claimAuction` on the same\noffer (for all pending refunds on it) — so a stale refund is retried on the\nvery next interaction with that auction, not just via an explicit\n`claimBack`.\n\n## Auction bid-step formula\n\nTranscribed from `MarketplaceV2.PlaceBid` (`Marketplace.cs`):\n\n- **First bid** on an auction (`CurrentBid <= 0`) must be\n `>= max(1, StartingBid)`.\n- **Every subsequent bid** must be at least\n `CurrentBid + step`, where\n `step = max(stepFromPercent, max(1, MinBidStepAbsolute))` and\n `stepFromPercent = ceil(CurrentBid * MinBidStepPercent)` when\n `MinBidStepPercent > 0`, else `1`.\n\nA rejected bid's error message already contains the computed minimum —\n`\"Bid must be at least {minAcceptable}.\"` — so a client doesn't need to\nreimplement this to show a useful error, only to build a live \"next valid bid\"\npreview in the UI.\n\nA bid within `AntiSnipeWindowSeconds` of the current `ExpiresAt` (and under\n`MaxAntiSnipeExtensions`) also pushes `ExpiresAt` out by\n`AntiSnipeExtensionSeconds` — reflected in `MarketplacePlaceBidResponse.ExpiresAt`\nfor that call, so re-render your countdown from the response rather than a\nvalue computed before the bid.\n\n---\n\n## User state: UserMarketplaceState (rate limits)\n\nReturned inside `getMyState()`'s `Limits` field and mirrored to\n`client.data.user.state?.Marketplace`:\n\n```ts\ninterface UserMarketplaceState {\n Create?: MarketplaceActionCounter; // listings + auctions + buy orders + direct trades created\n Buy?: MarketplaceActionCounter; // Buy, and the buyer/creator side of FillBuyOrder\n Sell?: MarketplaceActionCounter; // the seller/filler side of Buy and FillBuyOrder\n Bid?: MarketplaceActionCounter;\n}\n\ninterface MarketplaceActionCounter {\n LastAt?: string; // ISO timestamp of the last action\n DailyCount?: number;\n DailyResetUtc?: string; // when DailyCount next resets (next UTC midnight after LastAt)\n}\n```\n\nThese mirror whichever `CreateLimits`/`BuyLimits`/`FillLimits`/`BidLimits`\n(`LimitSpec` — `CooldownSeconds` + `DailyCap`) the relevant per-type settings\nconfigured — use them to grey out a \"Create listing\" button once `DailyCount`\nhits the config's `DailyCap`. `Sell` is incremented for whichever party ends\nup supplying goods (the listing seller on `Buy`, the filler on\n`FillBuyOrder`) even though the action that triggered it was initiated by the\ncounterparty — read the two counters as \"goods flowed out\" (`Sell`) vs\n\"goods flowed in\" (`Buy`), not \"I clicked buy\" vs \"I clicked sell\".\n\n---\n\n## Responses\n\n### MarketplaceGroupedOffersResponse / MarketplaceBrowseResponse\n\n```ts\ninterface MarketplaceGroupedOfferView {\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n OfferCount?: number;\n}\ninterface MarketplaceGroupedOffersResponse {\n Groups?: MarketplaceGroupedOfferView[];\n}\n\ninterface MarketplaceBrowseResponse {\n Offers?: MarketplaceOfferView[];\n ContinuationToken?: string; // pass back in to page further\n}\n```\n\n`getGroupedOffers()` is a storefront summary (one row per item + how many\n`Listing`/`Auction` offers exist) — it carries **no price field at all** (not\neven a min/max), confirmed against\n`MarketplaceDBService.GetGroupedActiveOffersAsync`'s aggregation, which\nprojects only the count. Drill into a specific item with `getOffersByItem` to\nget the actual `MarketplaceOfferView[]` (with `Price`/`Auction`) to buy/bid\nfrom.\n\n### MarketplaceCreateOfferResponse\n\n```ts\ninterface MarketplaceCreateOfferResponse {\n OfferID?: string;\n Offer?: MarketplaceOfferView;\n Resources?: ResourceOperation; // the creator's own escrow charge (goods/payment + listing fee)\n}\n```\n\n### MarketplaceSettlementResponse\n\nReturned by cancel/buy/claimAuction/fillBuyOrder/respondDirectTrade/claimBack.\n**Exactly one** of `Settlement` or `Resources` is populated, depending on the\naction:\n\n```ts\ninterface MarketplaceSettlementResponse {\n OfferID?: string;\n Status?: MarketOfferStatus;\n Settlement?: ResourceDualPartyResult; // dual-party: buy, fillBuyOrder, respondDirectTrade accept-with-price\n Resources?: ResourceOperation; // single-party: cancel*, claimBack, claimAuction, respondDirectTrade accept-gift/decline\n CommissionTaken?: ResourceBundle; // populated when the Commission policy took a cut\n}\n```\n\n`CommissionTaken` is populated on `buy`, `fillBuyOrder`,\n`respondDirectTrade` (commission-applicable swap), and the **seller's**\n`claimAuction` call — it is `null` on the **winner's** `claimAuction` call\n(the winner's `Resources` is a pure goods grant with nothing to report a\ncommission on), confirmed in `MarketplaceV2.ClaimAuction`:\n`CommissionTaken = isSeller ? fee : null`.\n\nSee [Settlement & escrow model](#settlement--escrow-model) below for exactly\nwhich branch each caller sees, including the direct-trade-decline special\ncase.\n\n### MarketplacePlaceBidResponse\n\n```ts\ninterface MarketplacePlaceBidResponse {\n OfferID?: string;\n CurrentBid?: number;\n BidCount?: number;\n ExpiresAt?: string; // may have moved out due to anti-snipe\n Resources?: ResourceOperation; // the caller's own bid-escrow charge\n}\n```\n\n### MarketplaceMyStateResponse\n\n```ts\ninterface MarketplaceMyStateResponse {\n MyOffers?: MarketplaceOfferView[]; // my active listings/auctions (Status === \"Active\" only)\n MyLeadingBids?: MarketplaceOfferView[]; // active auctions where I'm currently winning\n MyBuyOrders?: MarketplaceOfferView[];\n IncomingTrades?: MarketplaceOfferView[]; // direct trades targeting me, awaiting my response\n OutgoingTrades?: MarketplaceOfferView[]; // direct trades I sent, awaiting their response\n Claimables?: MarketplaceOfferView[]; // ended auctions / expired offers with escrow to claim\n DrainedRefunds?: MarketplaceBidRefund[]; // refunds settled by THIS call, if any were pending\n Limits?: UserMarketplaceState;\n}\n```\n\n`Claimables` is a de-duplicated union (by `OfferID`) of five server-side\nchecks (`MarketplaceV2.GetMyState`): my expired listings/auctions with no\nbidder and unreturned escrow, my ended auctions with a bidder whose proceeds\naren't claimed yet, my expired buy orders with unreturned escrow, my expired\noutgoing direct trades with unreturned escrow, and auctions I'm leading whose\n`ExpiresAt` has passed but I haven't claimed goods on yet. It's the list to\ndrive a \"you have items/currency to collect\" badge — the direct signal for\nwhen to prompt `claimAuction`/`claimBack`. `DrainedRefunds` reflects only\nrefunds this specific call happened to settle (a side effect of `getMyState`\nopportunistically draining the caller's own outbox) — it is not a running\nhistory of all past refunds.\n\n### MarketplaceHistoryResponse\n\n```ts\ninterface MarketplaceHistoryEntryView {\n OfferID?: string;\n OfferType?: MarketOfferType;\n FinalStatus?: MarketOfferStatus;\n SellerUserID?: string;\n BuyerUserID?: string;\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n GoodsAmount?: number;\n PricePaid?: ResourceBundle;\n CommissionTaken?: ResourceBundle;\n CompletedAt?: string;\n}\ninterface MarketplaceHistoryResponse {\n Entries?: MarketplaceHistoryEntryView[];\n ContinuationToken?: string;\n}\n```\n\nA read-only settled-trade ledger, one entry per completed/cancelled/declined/\nexpired offer that reached a terminal write — this is the source for a \"trade\nhistory\" screen, distinct from `MyOffers` (which is current/active state).\nEntries come from an append-only log with a deterministic `LogID`\n(`\"log_{status}_{offerID}\"`), so a transaction retry can never duplicate a\nhistory row.\n\n---\n\n## Request shape\n\nEvery method builds a shared `MarketplaceRequest` internally (extends\n`BaseRequest`); you don't construct this yourself, but it's useful to know\nwhich params round-trip together on the wire:\n\n```ts\ninterface MarketplaceRequest extends BaseRequest {\n OfferID?: string;\n ItemID?: string;\n CatalogID?: string;\n GoodsAmount?: number;\n ItemInstanceIDs?: string[];\n PriceBundle?: ResourceBundle;\n RequestedBundle?: ResourceBundle;\n DurationHours?: number;\n BidAxisType?: ResourceEntryType;\n BidCurrencyID?: string;\n BidCatalogID?: string;\n BidItemID?: string;\n StartingBid?: number;\n BidAmount?: number;\n TargetUserID?: string;\n Accept?: boolean;\n PageSize?: number;\n ContinuationToken?: string;\n OfferTypeFilter?: MarketOfferType;\n}\n```\n\nOne field name is reused across two different roles depending on the action:\n`ItemInstanceIDs` on a create call means \"escrow these specific instances as\nthe goods\"; it's absent on read/bid/response calls. `PageSize` is clamped\nserver-side to `1..100` regardless of what the client sends\n(`MarketplaceDBService.FindHistoryAsync` and friends).\n\n`MarketplaceAction` enumerates the 20 backend action names this request shape\ndispatches on (`GetDefinitions`, `GetGroupedOffers`, `GetOffersByItem`,\n`GetOffer`, `GetBuyOrders`, `GetMyState`, `GetHistory`, `CreateListing`,\n`CancelListing`, `Buy`, `CreateAuction`, `PlaceBid`, `ClaimAuction`,\n`CreateBuyOrder`, `CancelBuyOrder`, `FillBuyOrder`, `CreateDirectTrade`,\n`RespondDirectTrade`, `CancelDirectTrade`, `ClaimBack`) — one more than the 19\npublic `MarketplaceService` methods because `getOffersByItem`/`getBuyOrders`\nboth exist as distinct client methods but the action list also separately\nnames every read; you won't call this directly, it's internal routing.\n\n---\n\n## Settlement & escrow model\n\nEvery create call escrows something immediately, in the same transaction as\nthe offer's insert:\n\n| Create call | What gets escrowed |\n| ------------------- | -------------------------------------------------------------------------------------------- |\n| `createListing` | The goods (`itemInstanceIDs` or `goodsAmount` of `itemID`), plus `ListingFee` if configured. |\n| `createAuction` | The goods, same as a listing (no separate fee field on auctions). |\n| `createBuyOrder` | The `priceBundle` (payment), not goods — inverted from a listing. |\n| `createDirectTrade` | The offered side (instances and/or bundle) — nothing from the target until they accept. |\n| `placeBid` | The bid amount — refunded to the previous leader via `PendingRefunds` when outbid. |\n\nSettlement then plays out one of three ways:\n\n1. **Instant dual-party** — `buy`, `fillBuyOrder`, and\n `respondDirectTrade(offerID, true)` when a `requestedBundle` was set: the\n backend computes both sides in one atomic step\n (`ResourceService.ApplyDualPartyAtomicAsync`) and returns\n `ResourceDualPartyResult` (`FromUserID`/`ToUserID` +\n `FromResult`/`ToResult`, each a `ResourceOperation`). The SDK applies\n **only the calling player's side** — it checks which of `FromUserID`/\n `ToUserID` matches the current session and applies that result, ignoring\n the other party's (there is no way to see the counterparty's resulting\n balances from this call).\n2. **Instant single-party grant** — `respondDirectTrade(offerID, true)` on a\n pure gift (no `requestedBundle`): only `Resources` is populated, applied\n directly to the accepter.\n3. **Lazy per-side claim** — auctions never auto-settle. After\n `ExpiresAt`, the winning bidder and the seller each call `claimAuction`\n independently; each call returns that caller's own `Resources` (goods to\n the winner — the bid amount was already paid at `placeBid` time; net\n proceeds to the seller). Nothing forces both sides to claim promptly — an\n unclaimed auction just sits in `Claimables`.\n\n**The direct-trade-decline special case**: `respondDirectTrade(offerID,\nfalse)` returns a `Resources` grant, but it is the **escrow being returned to\nthe offer's `CreatorUserID`** (the sender getting their goods back), not a\ngrant to the decliner. Confirmed against the backend source\n(`Marketplace.cs`): decline calls `ReturnEscrowToInitiatorAsync`, which runs\n`ResourceService.ApplyResourceOperationAtomicAsync` with\n`userID: offer.CreatorUserID` — never `service.UserID` (the caller). The SDK\nintentionally does **not** apply this `Resources` to the local cache in that\nbranch — applying it would incorrectly credit the decliner's own balances with\nresources that were actually returned to someone else's account. If you need\nto reflect \"trade declined\" in the decliner's UI, do it by removing the trade\nfrom `IncomingTrades`/showing `Status: \"Declined\"` — not by expecting a\nbalance change.\n\n`cancelListing`/`cancelBuyOrder`/`cancelDirectTrade` and `claimBack` are all\nsingle-party grants (`Resources` only) refunding the **caller's own** escrow —\nstraightforward `applyResourceOperation` cases, no dual-party ambiguity. A\ncancel is only legal on an offer the caller created, while it's still\n`Active`; an auction that already has a bidder cannot be cancelled at all\n(bids are already in escrow — the only exit is `claimAuction` after\n`ExpiresAt`).\n\n---\n\n## Commission model\n\nComputed per price position (each `ResourceEntry` and each `EventTokenOperation`\nin the price bundle independently) at settlement, transcribed verbatim from\n`MarketplaceHelpers.ComputeFee`/`SplitCommission` (`MarketplaceHelpers.cs`):\n\n```\nfee = min(amount, max(ceil(amount * Percent), MinPerPosition))\nnet = amount - fee\n```\n\n- `ceil` rounds the percentage cut up to the next whole unit.\n- `MinPerPosition` is a floor, not an addition — it only raises `fee` when the\n percentage cut would be smaller than the minimum.\n- `fee` is clamped to never exceed `amount` itself (so a price position with a\n huge `MinPerPosition` can zero out the seller's net for that position, but\n never make it negative).\n- A position where the resulting `net` is `0` is dropped entirely from the\n net bundle; likewise a `fee` of `0` is dropped from the fee bundle — so\n `CommissionTaken` only lists positions that actually had something taken.\n\nThe effective `(Percent, MinPerPosition)` pair is resolved once, at **offer\ncreation time**, and snapshotted onto the offer\n(`CommissionPercentSnapshot`/`CommissionMinSnapshot`/`CommissionSinkSnapshot`)\n— `PerCatalogOverrides[GoodsCatalogID]` if present, else the top-level\n`Percent`/`MinPerPosition` (`MarketplaceHelpers.ResolveCommission`). This\nmeans a later change to the title's commission config **never** affects an\nalready-listed offer; only new offers pick up the new rate. For a\n`DirectTrade`, the snapshot is `(0, 0)` unless\n`Commission.ApplyToDirectTrades` was `true` **and** the trade was a\nswap (not a gift) at creation time.\n\n`Sink`: `\"Burn\"` (default) destroys the commission outright — it's simply the\ndifference between what the buyer/payer consumed and what the seller was\ngranted, with no corresponding write anywhere. `\"Ledger\"` additionally credits\n`LedgerAccountID` (default `\"default\"` if empty) in the title's\n`MarketplaceLedgerDocument`, in the same transaction as the settlement — an\naccounting/reporting concept internal to the title's economy, with no\nclient-visible effect beyond `CommissionTaken` on the response. Either way,\nwhat the trading parties themselves receive is identical — the sink only\nchanges whether the fee is destroyed or recorded for the title's own\nbookkeeping.\n\n---\n\n## Server-side limits, locking, and idempotency\n\nVerified against `Marketplace.cs`/`MarketplaceHelpers.cs`/`MarketplaceDefinitions.cs`:\n\n- **Per-endpoint rate limit**: every `MarketplaceV2` action shares a 500ms\n IP-level rate limit (`RateLimitMilliseconds = 500` in `Marketplace.cs`,\n enforced by `ClientRun.Execute`) — a repeat call inside that window surfaces\n to the SDK as `reason: \"throttled\"`.\n- **Per-offer mutation lock**: `buy`, `placeBid`, `claimAuction`,\n `fillBuyOrder`, `respondDirectTrade`, every `cancel*`, and `claimBack(offerID)`\n additionally take a short-lived Redis lock keyed on the specific `OfferID`\n (200ms rate window, 5000ms hold, `Marketplace.cs`'s `AcquireOfferLockAsync`)\n — this is on top of the per-endpoint rate limit and is scoped to that one\n offer, not the whole action. A concurrent second call on the same offer\n (yours or another player racing you for the same listing) fails with either\n `\"Too many requests for this offer, try again.\"` or `\"This offer is being\nprocessed, try again.\"` — both are safe to retry. The Mongo-side\n precondition (`PatchOfferInSessionAsync`'s optimistic-concurrency filter) is\n the actual source of truth against double-sell/double-claim; the Redis lock\n is only a latency optimization, so correctness doesn't depend on the client\n handling this perfectly.\n- **Per-player anti-abuse caps** (checked before escrow, in addition to\n `LimitSpec` cooldown/daily-cap pairs): `MaxActiveListings` (Listing+Auction\n combined), `MaxActiveOrders` (BuyOrder), `MaxPendingOutgoing` (DirectTrade,\n counts only the sender's still-`Active` outgoing offers). Each rejection\n names the current count and the cap, e.g. `\"You have too many open offers\n(10/10).\"`\n- **Dedup / atomicity**: there is no batch/multi-item marketplace action —\n every method operates on exactly one offer per call, so there's no\n partial-success array to interpret (contrast with modules that expose\n `BatchItemResult[]`). Every create is one atomic operation (escrow debit +\n offer insert + limit-counter patch in a single transaction via\n `ResourceService.ApplyResourceOperationAtomicAsync`); every settlement is\n one atomic operation touching both parties via\n `ResourceService.ApplyDualPartyAtomicAsync`. Resources for a create call\n live on `MarketplaceCreateOfferResponse.Resources`; for a settlement, on\n `MarketplaceSettlementResponse.Settlement` (dual-party) or `.Resources`\n (single-party) — there is no separate top-level resources field to check.\n- **Idempotency**: create calls (`createListing`/`createAuction`/\n `createBuyOrder`/`createDirectTrade`) resolve their idempotency key via\n `ResourceService.ResolveRelatedEntityID(args.RelatedEntityID, \"mp_create_{userID}\" | \"mp_order_{userID}\" | \"mp_trade_{userID}\")`\n — passing a stable `relatedEntityID` on repeated client-side retries of the\n _same_ create prevents a duplicate offer/charge; omitting it means every\n call is treated as a new create. Settlement actions key off the offer\n itself (e.g. `\"MpBuy:{offerID}\"`, `\"MpBid:{offerID}:{bidIndex}\"`,\n `\"MpAuctionGoods:{offerID}\"`), so retrying the exact same settlement call is\n naturally idempotent without the client needing to supply anything.\n"
8
+ "content": "# Marketplace data model — reference\n\nFull shape of the config (`MarketplaceDefinitions`), the offer/state views the\nclient actually sees, the request shape, and the escrow/settlement/commission\nmodel in detail. All of these are **strictly typed in the SDK** —\n`MarketplaceDefinitions` and every nested block, `MarketplaceOfferView`,\n`MarketplaceAuctionState`, the various response types — are exported from\n`@idosgames/core`, so `getDefinitions()` /\n`getSection<MarketplaceDefinitions>(\"Marketplace\")` and every service call give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\n## Contents\n\n- [What the client never sees](#what-the-client-never-sees)\n- [Config: MarketplaceDefinitions](#config-marketplacedefinitions)\n- [Per-listing-type settings](#per-listing-type-settings)\n- [Offer view: MarketplaceOfferView](#offer-view-marketplaceofferview)\n- [Auction state](#auction-state)\n- [Auction bid-step formula](#auction-bid-step-formula)\n- [User state: UserMarketplaceState (rate limits)](#user-state-usermarketplacestate-rate-limits)\n- [Responses](#responses)\n- [Request shape](#request-shape)\n- [Settlement & escrow model](#settlement--escrow-model)\n- [Commission model](#commission-model)\n- [Server-side limits, locking, and idempotency](#server-side-limits-locking-and-idempotency)\n\n---\n\n## What the client never sees\n\nThe backend's internal `MarketplaceOfferDocument` (raw escrow bookkeeping,\ncommission snapshot) and the `MarketplaceTradeLogDocument` /\n`MarketplaceLedgerDocument` never reach the client. Everything documented here\nis the **public projection**: `MarketplaceOfferView` (what browsing/my-state/\nhistory return instead of the raw document) and `MarketplaceHistoryEntryView`.\nOne exception: `MarketplaceAuctionState` is shared **verbatim** between the\ninternal document and the public view — the backend assigns the same object to\nboth (`MarketplaceHelpers.BuildOfferView`, `Auction = doc.Auction`) — so what's\nbelow is exactly what's stored.\n\n---\n\n## Config: MarketplaceDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MarketplaceDefinitions>(\"Marketplace\")`.\n\n```ts\ninterface MarketplaceDefinitions {\n Enabled?: boolean; // master switch; default false — must be enabled explicitly\n Gate?: SegmentGate; // segment-based access gate (see other modules' Gate usage)\n Schedule?: ScheduleSpec; // time-window the marketplace is open; null = always open\n PricePolicy?: MarketplacePricePolicy;\n Commission?: MarketplaceCommissionPolicy;\n Tradability?: MarketplaceTradabilityPolicy;\n Listings?: MarketplaceListingSettings;\n Auctions?: MarketplaceAuctionSettings;\n BuyOrders?: MarketplaceBuyOrderSettings;\n DirectTrades?: MarketplaceDirectTradeSettings;\n}\n```\n\n`getDefinitions()`'s response also carries two computed flags alongside\n`Definitions`:\n\n```ts\ninterface MarketplaceGetDefinitionsResponse {\n Definitions?: MarketplaceDefinitions | null;\n IsOpenNow?: boolean | null; // Enabled && Schedule evaluated against server time\n GatePassed?: boolean | null; // Gate evaluated against this player's segment\n}\n```\n\nCheck both before showing \"create offer\" UI — `Enabled` alone doesn't account\nfor the schedule window or the segment gate. **`Gate`/`Schedule` are only\nenforced on trading actions** (create/buy/bid/fill/accept) — `claimBack`,\ncancels, and a decline are never blocked by either, confirmed in\n`MarketplaceV2.CheckTradeGate`'s call sites (`Marketplace.cs`): it's invoked\nfrom `CreateSellOffer`, `Buy`, `PlaceBid`, `CreateBuyOrder`, `FillBuyOrder`,\n`CreateDirectTrade`, and the accept branch of `RespondDirectTrade` — never\nfrom `CancelOffer`, `ClaimBack`, or the decline branch.\n\n`SegmentGate`, `ScheduleSpec`, and `LimitSpec` (used throughout the per-type\nsettings below) are generic cross-module shapes shared with other live-ops\nsystems (segment/schedule gating, anti-farm counters) — this reference only\nlists the fields Marketplace itself reads.\n\n---\n\n## Per-listing-type settings\n\n### PricePolicy — what a price/bid can be made of\n\n```ts\ninterface MarketplacePricePolicy {\n AllowVirtualCurrency?: boolean; // default true; ignored when Allowed is non-empty\n AllowItems?: boolean; // default false; ignored when Allowed is non-empty\n AllowEventTokens?: boolean; // default false; ignored when Allowed is non-empty\n Allowed?: EntryResourceRule[]; // authoritative whitelist when non-empty (kind + id + min/max amount)\n MaxPositions?: number; // default 5; cap on distinct resource entries + event tokens in one price; 0 = unlimited\n}\n```\n\nMirrors the entry-policy pattern from the Match module (`MatchEntrySettings`) —\nsame `EntryResourceRule` shape (`Kind`, `CurrencyID`/`CatalogID`/`ItemID`,\n`TokenType`/`EntityID`, `MinAmount`/`MaxAmount`). A `createListing`/\n`createAuction`/`createBuyOrder`/`createDirectTrade`'s price (or an auction's\nstarting-bid axis) is validated against this policy on create\n(`MarketplaceHelpers.ValidatePricePolicy`, `Marketplace.cs`):\n\nTwo rules apply **unconditionally**, regardless of whitelist/flags\n(`MarketplaceHelpers.cs`):\n\n- A virtual-currency price position is rejected unless that currency's own\n `VirtualCurrencyDefinition.Permissions.IsTradable === true` — error:\n `\"Currency '{id}' is not tradable between players (Permissions.IsTradable=false).\"`\n- An **unstackable item can never be used as a price position** (only as\n goods) — error: `\"Unstackable items cannot be used as a price.\"` — because\n granting it back would have to re-mint a new instance identity.\n\nWhen `Allowed` is non-empty it is authoritative — every price position must\nmatch one of its rules (including `MinAmount`/`MaxAmount`); the\n`AllowVirtualCurrency`/`AllowItems`/`AllowEventTokens` flags are only consulted\nwhen `Allowed` is empty.\n\n### Commission — the cut taken at settlement\n\n```ts\ninterface MarketplaceCommissionPolicy {\n Percent?: number; // default 0.05 (5%)\n MinPerPosition?: number; // floor per priced resource entry; default 0 (no minimum)\n Sink?: \"Burn\" | \"Ledger\"; // default \"Burn\"\n LedgerAccountID?: string; // target account when Sink === \"Ledger\"; empty falls back to \"default\"\n PerCatalogOverrides?: Record<string, MarketplaceCommissionOverride>;\n ApplyToDirectTrades?: boolean; // default false — gifts/trades are commission-exempt by default\n}\n\ninterface MarketplaceCommissionOverride {\n Percent?: number;\n MinPerPosition?: number;\n}\n```\n\n`PerCatalogOverrides` is keyed by the **goods'** `CatalogID` (not the price's) —\na catalog present here replaces the top-level `Percent`/`MinPerPosition` for\noffers whose goods come from that catalog\n(`MarketplaceHelpers.ResolveCommission`). See\n[Commission model](#commission-model) below for the exact per-position fee\nformula.\n\n### Tradability — what's allowed on the market at all\n\n```ts\ninterface MarketplaceTradabilityPolicy {\n AllowedCatalogIDs?: string[]; // null/absent = no catalog allow-list (all allowed unless denied)\n DeniedCatalogIDs?: string[];\n DeniedItemIDs?: string[];\n AllowUnstackableWithState?: boolean; // default true; allow listing instances with state (Level>1, RemainingUses>1, CustomData)\n}\n```\n\nBase requirement, checked first regardless of policy:\n`ItemDefinition.IsTradable` must be `true`\n(`MarketplaceHelpers.ResolveTradableItem`) — error:\n`\"Item '{itemID}' is not tradable (ItemDefinition.IsTradable=false).\"` The item\nis resolved with the same strict→fallback catalog resolver used elsewhere\n(self-heals `CatalogID` if the item moved catalogs), so the response's\n`GoodsCatalogID` can differ from what you originally passed as `catalogID`.\n\nWhen `AllowUnstackableWithState` is `false`, only \"pristine\" instances\n(`Level <= 1`, `RemainingUses <= 1`, no `CustomData`) may be escrowed as goods\n— error: `\"Instance '{id}' has state (level/custom data) — selling stateful\ninstances is disabled.\"` Equipped or expired instances are always rejected\nregardless of this flag (`\"Instance '{id}' is equipped — unequip it\nfirst.\"` / `\"Instance '{id}' has expired.\"`).\n\n### Listings\n\n```ts\ninterface MarketplaceListingSettings {\n Enabled?: boolean; // default true (when the module itself is enabled)\n AllowedDurationsHours?: number[]; // empty/null = default {24, 72, 168}\n MaxActiveListings?: number; // default 10; per-player cap counting Listing+Auction together; 0 = unlimited\n CreateLimits?: LimitSpec;\n BuyLimits?: LimitSpec;\n ListingFeeOptions?: Record<string, PriceOption>; // ways to pay the fee charged in addition to escrowing the goods; only the Standard part is used, and the fee is never paid in a store (P2P + refundable)\n RefundListingFeeOnCancel?: boolean; // default false — fee is forfeit on cancel; never refunded on expiry either way\n}\n```\n\n`MaxActiveListings` is checked against a single count query across **both**\n`Listing` and `Auction` offer types created by the player\n(`MarketplaceDBService.CountActiveByCreatorAsync(..., Listing, Auction)`) — a\ntitle with `MaxActiveListings: 10` caps the player at 10 combined open\nlistings+auctions, not 10 of each.\n\n### Auctions\n\n```ts\ninterface MarketplaceAuctionSettings {\n Enabled?: boolean; // default true\n MinDurationHours?: number; // default 1\n MaxDurationHours?: number; // default 168\n AntiSnipeWindowSeconds?: number; // default 120; 0 = disabled\n AntiSnipeExtensionSeconds?: number; // default 120\n MaxAntiSnipeExtensions?: number; // default 10\n MinBidStepPercent?: number; // default 0.05 (5%)\n MinBidStepAbsolute?: number; // default 1\n BidLimits?: LimitSpec;\n}\n```\n\nAnti-snipe: a bid placed within `AntiSnipeWindowSeconds` of the auction's\ncurrent `ExpiresAt` pushes the deadline out by `AntiSnipeExtensionSeconds`,\nup to `MaxAntiSnipeExtensions` times total per auction —\n`MarketplaceAuctionState.ExtensionCount` tracks how many have already\nhappened (`Marketplace.cs`, `PlaceBid`). See\n[Auction bid-step formula](#auction-bid-step-formula) for the minimum-bid math.\n\n### Buy orders\n\n```ts\ninterface MarketplaceBuyOrderSettings {\n Enabled?: boolean; // default true\n MaxActiveOrders?: number; // default 5; per-player cap; 0 = unlimited\n AllowedDurationsHours?: number[]; // empty/null = default {24, 72, 168}\n CreateLimits?: LimitSpec;\n FillLimits?: LimitSpec;\n}\n```\n\n### Direct trades\n\n```ts\ninterface MarketplaceDirectTradeSettings {\n Enabled?: boolean; // default true\n OfferExpirationHours?: number; // default 168\n MaxPendingOutgoing?: number; // default 10; caps my own not-yet-responded-to outgoing trades; 0 = unlimited\n AllowGifts?: boolean; // default true; false = requestedBundle becomes mandatory\n CreateLimits?: LimitSpec;\n}\n```\n\n---\n\n## Offer view: MarketplaceOfferView\n\nWhat browsing, my-state, and single-offer reads return — the public\nprojection of an offer, regardless of type:\n\n```ts\ninterface MarketplaceOfferView {\n OfferID?: string;\n OfferType?: MarketOfferType; // \"Listing\" | \"Auction\" | \"BuyOrder\" | \"DirectTrade\"\n Status?: MarketOfferStatus; // \"Active\" | \"Completed\" | \"Cancelled\" | \"Declined\" | \"Expired\"\n CreatorUserID?: string;\n CreatorPublicData?: UserPublicDataModel; // display name/avatar for storefront rendering\n TargetUserID?: string; // set only for DirectTrade\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n GoodsAmount?: number;\n GoodsInstances?: UnstackableItemInstanceState[]; // populated when the goods are specific unstackable instances\n Price?: ResourceBundle; // Listing/BuyOrder asking price, or DirectTrade's requestedBundle\n Auction?: MarketplaceAuctionState; // populated only for OfferType === \"Auction\"\n CreatedAt?: string; // ISO timestamp\n ExpiresAt?: string;\n}\n```\n\n`Status: \"Expired\"` is set **lazily** on the read path itself\n(`MarketplaceHelpers.BuildOfferView`): an `Active` document whose `ExpiresAt`\nhas passed is projected as `\"Expired\"` in the view without writing anything to\nthe database — the authoritative DB flip only happens when the offer is\nactually claimed/finalized. Don't assume an offer past its `ExpiresAt` has\nalready flipped in storage just because a read showed `\"Expired\"`.\n\n`GoodsInstances` is `null` for `BuyOrder` offers even if the eventual goods\nwill be unstackable (a buy order names an `ItemID`/`CatalogID`, not specific\ninstances — the filler supplies fresh ones). For the other three offer types\nit's populated when the goods are unstackable, using the same\n`UnstackableItemInstanceState` shape as `InventoryV2.UnstackableItems` (see the\nitem-system skill) — `ItemInstanceID`, `Level`, `ExpiresAt`, `EquippedSlot`\n(always `null` in escrow), etc.\n\n---\n\n## Auction state\n\n```ts\ninterface MarketplaceAuctionState {\n BidAxisType?: ResourceEntryType; // \"VirtualCurrency\" | \"Item\" — see currency-system skill\n BidCurrencyID?: string; // set when BidAxisType is a currency\n BidCatalogID?: string; // set when BidAxisType is an item\n BidItemID?: string;\n StartingBid?: number;\n CurrentBid?: number; // 0 = no bids yet\n CurrentBidderID?: string;\n BidCount?: number;\n ExtensionCount?: number; // anti-snipe extensions used so far\n PendingRefunds?: MarketplaceBidRefund[]; // outbid players awaiting refund\n}\n\ninterface MarketplaceBidRefund {\n BidIndex?: number;\n UserID?: string;\n Amount?: number;\n Settled?: boolean; // false = still owed; drained by claimBack, getMyState, or a later bid/claim on the same offer\n SettledAt?: string;\n}\n```\n\nBidding uses a **single comparable axis** — exactly one VC or one stackable\nitem type — so bids can be strictly ordered. `PendingRefunds` is the outbox of\nplayers who were outbid: `Settled: false` entries are what `claimBack()` (no\n`offerID`) drains across all of a player's auctions in one call. The backend\nalso opportunistically drains refunds on `getMyState()` (for the calling\nplayer's own refunds) and after any `placeBid`/`claimAuction` on the same\noffer (for all pending refunds on it) — so a stale refund is retried on the\nvery next interaction with that auction, not just via an explicit\n`claimBack`.\n\n## Auction bid-step formula\n\nTranscribed from `MarketplaceV2.PlaceBid` (`Marketplace.cs`):\n\n- **First bid** on an auction (`CurrentBid <= 0`) must be\n `>= max(1, StartingBid)`.\n- **Every subsequent bid** must be at least\n `CurrentBid + step`, where\n `step = max(stepFromPercent, max(1, MinBidStepAbsolute))` and\n `stepFromPercent = ceil(CurrentBid * MinBidStepPercent)` when\n `MinBidStepPercent > 0`, else `1`.\n\nA rejected bid's error message already contains the computed minimum —\n`\"Bid must be at least {minAcceptable}.\"` — so a client doesn't need to\nreimplement this to show a useful error, only to build a live \"next valid bid\"\npreview in the UI.\n\nA bid within `AntiSnipeWindowSeconds` of the current `ExpiresAt` (and under\n`MaxAntiSnipeExtensions`) also pushes `ExpiresAt` out by\n`AntiSnipeExtensionSeconds` — reflected in `MarketplacePlaceBidResponse.ExpiresAt`\nfor that call, so re-render your countdown from the response rather than a\nvalue computed before the bid.\n\n---\n\n## User state: UserMarketplaceState (rate limits)\n\nReturned inside `getMyState()`'s `Limits` field and mirrored to\n`client.data.user.state?.Marketplace`:\n\n```ts\ninterface UserMarketplaceState {\n Create?: MarketplaceActionCounter; // listings + auctions + buy orders + direct trades created\n Buy?: MarketplaceActionCounter; // Buy, and the buyer/creator side of FillBuyOrder\n Sell?: MarketplaceActionCounter; // the seller/filler side of Buy and FillBuyOrder\n Bid?: MarketplaceActionCounter;\n}\n\ninterface MarketplaceActionCounter {\n LastAt?: string; // ISO timestamp of the last action\n DailyCount?: number;\n DailyResetUtc?: string; // when DailyCount next resets (next UTC midnight after LastAt)\n}\n```\n\nThese mirror whichever `CreateLimits`/`BuyLimits`/`FillLimits`/`BidLimits`\n(`LimitSpec` — `CooldownSeconds` + `DailyCap`) the relevant per-type settings\nconfigured — use them to grey out a \"Create listing\" button once `DailyCount`\nhits the config's `DailyCap`. `Sell` is incremented for whichever party ends\nup supplying goods (the listing seller on `Buy`, the filler on\n`FillBuyOrder`) even though the action that triggered it was initiated by the\ncounterparty — read the two counters as \"goods flowed out\" (`Sell`) vs\n\"goods flowed in\" (`Buy`), not \"I clicked buy\" vs \"I clicked sell\".\n\n---\n\n## Responses\n\n### MarketplaceGroupedOffersResponse / MarketplaceBrowseResponse\n\n```ts\ninterface MarketplaceGroupedOfferView {\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n OfferCount?: number;\n}\ninterface MarketplaceGroupedOffersResponse {\n Groups?: MarketplaceGroupedOfferView[];\n}\n\ninterface MarketplaceBrowseResponse {\n Offers?: MarketplaceOfferView[];\n ContinuationToken?: string; // pass back in to page further\n}\n```\n\n`getGroupedOffers()` is a storefront summary (one row per item + how many\n`Listing`/`Auction` offers exist) — it carries **no price field at all** (not\neven a min/max), confirmed against\n`MarketplaceDBService.GetGroupedActiveOffersAsync`'s aggregation, which\nprojects only the count. Drill into a specific item with `getOffersByItem` to\nget the actual `MarketplaceOfferView[]` (with `Price`/`Auction`) to buy/bid\nfrom.\n\n### MarketplaceCreateOfferResponse\n\n```ts\ninterface MarketplaceCreateOfferResponse {\n OfferID?: string;\n Offer?: MarketplaceOfferView;\n Resources?: ResourceOperation; // the creator's own escrow charge (goods/payment + listing fee)\n}\n```\n\n### MarketplaceSettlementResponse\n\nReturned by cancel/buy/claimAuction/fillBuyOrder/respondDirectTrade/claimBack.\n**Exactly one** of `Settlement` or `Resources` is populated, depending on the\naction:\n\n```ts\ninterface MarketplaceSettlementResponse {\n OfferID?: string;\n Status?: MarketOfferStatus;\n Settlement?: ResourceDualPartyResult; // dual-party: buy, fillBuyOrder, respondDirectTrade accept-with-price\n Resources?: ResourceOperation; // single-party: cancel*, claimBack, claimAuction, respondDirectTrade accept-gift/decline\n CommissionTaken?: ResourceBundle; // populated when the Commission policy took a cut\n}\n```\n\n`CommissionTaken` is populated on `buy`, `fillBuyOrder`,\n`respondDirectTrade` (commission-applicable swap), and the **seller's**\n`claimAuction` call — it is `null` on the **winner's** `claimAuction` call\n(the winner's `Resources` is a pure goods grant with nothing to report a\ncommission on), confirmed in `MarketplaceV2.ClaimAuction`:\n`CommissionTaken = isSeller ? fee : null`.\n\nSee [Settlement & escrow model](#settlement--escrow-model) below for exactly\nwhich branch each caller sees, including the direct-trade-decline special\ncase.\n\n### MarketplacePlaceBidResponse\n\n```ts\ninterface MarketplacePlaceBidResponse {\n OfferID?: string;\n CurrentBid?: number;\n BidCount?: number;\n ExpiresAt?: string; // may have moved out due to anti-snipe\n Resources?: ResourceOperation; // the caller's own bid-escrow charge\n}\n```\n\n### MarketplaceMyStateResponse\n\n```ts\ninterface MarketplaceMyStateResponse {\n MyOffers?: MarketplaceOfferView[]; // my active listings/auctions (Status === \"Active\" only)\n MyLeadingBids?: MarketplaceOfferView[]; // active auctions where I'm currently winning\n MyBuyOrders?: MarketplaceOfferView[];\n IncomingTrades?: MarketplaceOfferView[]; // direct trades targeting me, awaiting my response\n OutgoingTrades?: MarketplaceOfferView[]; // direct trades I sent, awaiting their response\n Claimables?: MarketplaceOfferView[]; // ended auctions / expired offers with escrow to claim\n DrainedRefunds?: MarketplaceBidRefund[]; // refunds settled by THIS call, if any were pending\n Limits?: UserMarketplaceState;\n}\n```\n\n`Claimables` is a de-duplicated union (by `OfferID`) of five server-side\nchecks (`MarketplaceV2.GetMyState`): my expired listings/auctions with no\nbidder and unreturned escrow, my ended auctions with a bidder whose proceeds\naren't claimed yet, my expired buy orders with unreturned escrow, my expired\noutgoing direct trades with unreturned escrow, and auctions I'm leading whose\n`ExpiresAt` has passed but I haven't claimed goods on yet. It's the list to\ndrive a \"you have items/currency to collect\" badge — the direct signal for\nwhen to prompt `claimAuction`/`claimBack`. `DrainedRefunds` reflects only\nrefunds this specific call happened to settle (a side effect of `getMyState`\nopportunistically draining the caller's own outbox) — it is not a running\nhistory of all past refunds.\n\n### MarketplaceHistoryResponse\n\n```ts\ninterface MarketplaceHistoryEntryView {\n OfferID?: string;\n OfferType?: MarketOfferType;\n FinalStatus?: MarketOfferStatus;\n SellerUserID?: string;\n BuyerUserID?: string;\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n GoodsAmount?: number;\n PricePaid?: ResourceBundle;\n CommissionTaken?: ResourceBundle;\n CompletedAt?: string;\n}\ninterface MarketplaceHistoryResponse {\n Entries?: MarketplaceHistoryEntryView[];\n ContinuationToken?: string;\n}\n```\n\nA read-only settled-trade ledger, one entry per completed/cancelled/declined/\nexpired offer that reached a terminal write — this is the source for a \"trade\nhistory\" screen, distinct from `MyOffers` (which is current/active state).\nEntries come from an append-only log with a deterministic `LogID`\n(`\"log_{status}_{offerID}\"`), so a transaction retry can never duplicate a\nhistory row.\n\n---\n\n## Request shape\n\nEvery method builds a shared `MarketplaceRequest` internally (extends\n`BaseRequest`); you don't construct this yourself, but it's useful to know\nwhich params round-trip together on the wire:\n\n```ts\ninterface MarketplaceRequest extends BaseRequest {\n OfferID?: string;\n ItemID?: string;\n CatalogID?: string;\n GoodsAmount?: number;\n ItemInstanceIDs?: string[];\n PriceBundle?: ResourceBundle;\n RequestedBundle?: ResourceBundle;\n DurationHours?: number;\n BidAxisType?: ResourceEntryType;\n BidCurrencyID?: string;\n BidCatalogID?: string;\n BidItemID?: string;\n StartingBid?: number;\n BidAmount?: number;\n TargetUserID?: string;\n Accept?: boolean;\n PageSize?: number;\n ContinuationToken?: string;\n OfferTypeFilter?: MarketOfferType;\n}\n```\n\nOne field name is reused across two different roles depending on the action:\n`ItemInstanceIDs` on a create call means \"escrow these specific instances as\nthe goods\"; it's absent on read/bid/response calls. `PageSize` is clamped\nserver-side to `1..100` regardless of what the client sends\n(`MarketplaceDBService.FindHistoryAsync` and friends).\n\n`MarketplaceAction` enumerates the 20 backend action names this request shape\ndispatches on (`GetDefinitions`, `GetGroupedOffers`, `GetOffersByItem`,\n`GetOffer`, `GetBuyOrders`, `GetMyState`, `GetHistory`, `CreateListing`,\n`CancelListing`, `Buy`, `CreateAuction`, `PlaceBid`, `ClaimAuction`,\n`CreateBuyOrder`, `CancelBuyOrder`, `FillBuyOrder`, `CreateDirectTrade`,\n`RespondDirectTrade`, `CancelDirectTrade`, `ClaimBack`) — one more than the 19\npublic `MarketplaceService` methods because `getOffersByItem`/`getBuyOrders`\nboth exist as distinct client methods but the action list also separately\nnames every read; you won't call this directly, it's internal routing.\n\n---\n\n## Settlement & escrow model\n\nEvery create call escrows something immediately, in the same transaction as\nthe offer's insert:\n\n| Create call | What gets escrowed |\n| ------------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `createListing` | The goods (`itemInstanceIDs` or `goodsAmount` of `itemID`), plus the selected `ListingFeeOptions` option if configured. |\n| `createAuction` | The goods, same as a listing (no separate fee field on auctions). |\n| `createBuyOrder` | The `priceBundle` (payment), not goods — inverted from a listing. |\n| `createDirectTrade` | The offered side (instances and/or bundle) — nothing from the target until they accept. |\n| `placeBid` | The bid amount — refunded to the previous leader via `PendingRefunds` when outbid. |\n\nSettlement then plays out one of three ways:\n\n1. **Instant dual-party** — `buy`, `fillBuyOrder`, and\n `respondDirectTrade(offerID, true)` when a `requestedBundle` was set: the\n backend computes both sides in one atomic step\n (`ResourceService.ApplyDualPartyAtomicAsync`) and returns\n `ResourceDualPartyResult` (`FromUserID`/`ToUserID` +\n `FromResult`/`ToResult`, each a `ResourceOperation`). The SDK applies\n **only the calling player's side** — it checks which of `FromUserID`/\n `ToUserID` matches the current session and applies that result, ignoring\n the other party's (there is no way to see the counterparty's resulting\n balances from this call).\n2. **Instant single-party grant** — `respondDirectTrade(offerID, true)` on a\n pure gift (no `requestedBundle`): only `Resources` is populated, applied\n directly to the accepter.\n3. **Lazy per-side claim** — auctions never auto-settle. After\n `ExpiresAt`, the winning bidder and the seller each call `claimAuction`\n independently; each call returns that caller's own `Resources` (goods to\n the winner — the bid amount was already paid at `placeBid` time; net\n proceeds to the seller). Nothing forces both sides to claim promptly — an\n unclaimed auction just sits in `Claimables`.\n\n**The direct-trade-decline special case**: `respondDirectTrade(offerID,\nfalse)` returns a `Resources` grant, but it is the **escrow being returned to\nthe offer's `CreatorUserID`** (the sender getting their goods back), not a\ngrant to the decliner. Confirmed against the backend source\n(`Marketplace.cs`): decline calls `ReturnEscrowToInitiatorAsync`, which runs\n`ResourceService.ApplyResourceOperationAtomicAsync` with\n`userID: offer.CreatorUserID` — never `service.UserID` (the caller). The SDK\nintentionally does **not** apply this `Resources` to the local cache in that\nbranch — applying it would incorrectly credit the decliner's own balances with\nresources that were actually returned to someone else's account. If you need\nto reflect \"trade declined\" in the decliner's UI, do it by removing the trade\nfrom `IncomingTrades`/showing `Status: \"Declined\"` — not by expecting a\nbalance change.\n\n`cancelListing`/`cancelBuyOrder`/`cancelDirectTrade` and `claimBack` are all\nsingle-party grants (`Resources` only) refunding the **caller's own** escrow —\nstraightforward `applyResourceOperation` cases, no dual-party ambiguity. A\ncancel is only legal on an offer the caller created, while it's still\n`Active`; an auction that already has a bidder cannot be cancelled at all\n(bids are already in escrow — the only exit is `claimAuction` after\n`ExpiresAt`).\n\n---\n\n## Commission model\n\nComputed per price position (each `ResourceEntry` and each `EventTokenOperation`\nin the price bundle independently) at settlement, transcribed verbatim from\n`MarketplaceHelpers.ComputeFee`/`SplitCommission` (`MarketplaceHelpers.cs`):\n\n```\nfee = min(amount, max(ceil(amount * Percent), MinPerPosition))\nnet = amount - fee\n```\n\n- `ceil` rounds the percentage cut up to the next whole unit.\n- `MinPerPosition` is a floor, not an addition — it only raises `fee` when the\n percentage cut would be smaller than the minimum.\n- `fee` is clamped to never exceed `amount` itself (so a price position with a\n huge `MinPerPosition` can zero out the seller's net for that position, but\n never make it negative).\n- A position where the resulting `net` is `0` is dropped entirely from the\n net bundle; likewise a `fee` of `0` is dropped from the fee bundle — so\n `CommissionTaken` only lists positions that actually had something taken.\n\nThe effective `(Percent, MinPerPosition)` pair is resolved once, at **offer\ncreation time**, and snapshotted onto the offer\n(`CommissionPercentSnapshot`/`CommissionMinSnapshot`/`CommissionSinkSnapshot`)\n— `PerCatalogOverrides[GoodsCatalogID]` if present, else the top-level\n`Percent`/`MinPerPosition` (`MarketplaceHelpers.ResolveCommission`). This\nmeans a later change to the title's commission config **never** affects an\nalready-listed offer; only new offers pick up the new rate. For a\n`DirectTrade`, the snapshot is `(0, 0)` unless\n`Commission.ApplyToDirectTrades` was `true` **and** the trade was a\nswap (not a gift) at creation time.\n\n`Sink`: `\"Burn\"` (default) destroys the commission outright — it's simply the\ndifference between what the buyer/payer consumed and what the seller was\ngranted, with no corresponding write anywhere. `\"Ledger\"` additionally credits\n`LedgerAccountID` (default `\"default\"` if empty) in the title's\n`MarketplaceLedgerDocument`, in the same transaction as the settlement — an\naccounting/reporting concept internal to the title's economy, with no\nclient-visible effect beyond `CommissionTaken` on the response. Either way,\nwhat the trading parties themselves receive is identical — the sink only\nchanges whether the fee is destroyed or recorded for the title's own\nbookkeeping.\n\n---\n\n## Server-side limits, locking, and idempotency\n\nVerified against `Marketplace.cs`/`MarketplaceHelpers.cs`/`MarketplaceDefinitions.cs`:\n\n- **Per-endpoint rate limit**: every `MarketplaceV2` action shares a 500ms\n IP-level rate limit (`RateLimitMilliseconds = 500` in `Marketplace.cs`,\n enforced by `ClientRun.Execute`) — a repeat call inside that window surfaces\n to the SDK as `reason: \"throttled\"`.\n- **Per-offer mutation lock**: `buy`, `placeBid`, `claimAuction`,\n `fillBuyOrder`, `respondDirectTrade`, every `cancel*`, and `claimBack(offerID)`\n additionally take a short-lived Redis lock keyed on the specific `OfferID`\n (200ms rate window, 5000ms hold, `Marketplace.cs`'s `AcquireOfferLockAsync`)\n — this is on top of the per-endpoint rate limit and is scoped to that one\n offer, not the whole action. A concurrent second call on the same offer\n (yours or another player racing you for the same listing) fails with either\n `\"Too many requests for this offer, try again.\"` or `\"This offer is being\nprocessed, try again.\"` — both are safe to retry. The Mongo-side\n precondition (`PatchOfferInSessionAsync`'s optimistic-concurrency filter) is\n the actual source of truth against double-sell/double-claim; the Redis lock\n is only a latency optimization, so correctness doesn't depend on the client\n handling this perfectly.\n- **Per-player anti-abuse caps** (checked before escrow, in addition to\n `LimitSpec` cooldown/daily-cap pairs): `MaxActiveListings` (Listing+Auction\n combined), `MaxActiveOrders` (BuyOrder), `MaxPendingOutgoing` (DirectTrade,\n counts only the sender's still-`Active` outgoing offers). Each rejection\n names the current count and the cap, e.g. `\"You have too many open offers\n(10/10).\"`\n- **Dedup / atomicity**: there is no batch/multi-item marketplace action —\n every method operates on exactly one offer per call, so there's no\n partial-success array to interpret (contrast with modules that expose\n `BatchItemResult[]`). Every create is one atomic operation (escrow debit +\n offer insert + limit-counter patch in a single transaction via\n `ResourceService.ApplyResourceOperationAtomicAsync`); every settlement is\n one atomic operation touching both parties via\n `ResourceService.ApplyDualPartyAtomicAsync`. Resources for a create call\n live on `MarketplaceCreateOfferResponse.Resources`; for a settlement, on\n `MarketplaceSettlementResponse.Settlement` (dual-party) or `.Resources`\n (single-party) — there is no separate top-level resources field to check.\n- **Idempotency**: create calls (`createListing`/`createAuction`/\n `createBuyOrder`/`createDirectTrade`) resolve their idempotency key via\n `ResourceService.ResolveRelatedEntityID(args.RelatedEntityID, \"mp_create_{userID}\" | \"mp_order_{userID}\" | \"mp_trade_{userID}\")`\n — passing a stable `relatedEntityID` on repeated client-side retries of the\n _same_ create prevents a duplicate offer/charge; omitting it means every\n call is treated as a new create. Settlement actions key off the offer\n itself (e.g. `\"MpBuy:{offerID}\"`, `\"MpBid:{offerID}:{bidIndex}\"`,\n `\"MpAuctionGoods:{offerID}\"`), so retrying the exact same settlement call is\n naturally idempotent without the client needing to supply anything.\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\n\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\nrequest/response types. All of these are **strictly typed in the SDK** —\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<MatchDefinitions>(\"Match\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\nTerminology: the backend consistently calls the cost to participate **Entry**\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\nin the Match model. Use that vocabulary in any UI copy you generate.\n\n## Contents\n\n- [Player state](#player-state) — `UserMatchState`\n- [Match (offer)](#match-offer) — `PvPMatch`\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\n- [InstantBattleRule](#instantbattlerule)\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\n- [Net reward / burn formula](#net-reward--burn-formula)\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\n- [Request shape](#request-shape)\n\n---\n\n## Player state\n\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\nhydrates it at login.\n\n```ts\ninterface UserMatchState {\n PvPBattleStrategy?: BattleStepConfig[];\n CreationLimits?: UserMatchCreationLimitState | null;\n}\n\ninterface UserMatchCreationLimitState {\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\n DailyResetUtc?: string; // next UTC midnight reset\n}\n\ninterface BattleStepConfig {\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\n}\n```\n\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\nserver-side (`Match.CreationLimits` on the player document, written by\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\nfor a client to eventually show \"next match available in…\" UI, but nothing in\n`MatchService` currently reads it back into this cache slot — treat it as\ninformational/future until a response actually populates it for you.\n\n---\n\n## Match (offer)\n\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\nand `UpdateMatchResponse`.\n\n```ts\ninterface PvPMatch {\n MatchID: string;\n TitleID?: string;\n RuleID?: string;\n CreatedAt?: string;\n CreatorID?: string;\n CreatorCharacterID?: string;\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\n TargetUserID?: string; // set = private/targeted challenge; absent = public\n Entry?: ResourceBundle; // the creator's entry cost\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\n RefundCreationCostOnCancel?: boolean;\n JoinedByUserID?: string;\n JoinedByCharacterID?: string;\n JoinedAt?: string;\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\n WinnerUserID?: string; // absent/null on a draw\n CompletedAt?: string;\n IsRewardDistributed?: boolean;\n RewardDistributedAt?: string;\n}\n```\n\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\nmatch) are tracked separately — cancelling refunds the entry cost always, and\nthe creation fee only when `RefundCreationCostOnCancel` is true.\n\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\nresolves the battle synchronously in the same call, so a match goes directly\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\nenum for forward-compat / other match modes, not for instant-battle.\n\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\nmatch's strategy from `getMyMatches` or after you've fetched the match some\nother way; it isn't needed to join, since your own strategy is what you send\nto `instantBattle`.\n\n---\n\n## Battle result\n\nReturned inside `InstantBattleResponse.Battle`.\n\n```ts\ninterface BattleResult {\n WinnerUserID?: string; // absent on a draw\n LoserUserID?: string; // absent on a draw\n Entry?: ResourceBundle; // one side's entry cost that was in play\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\n IsDraw?: boolean;\n P1BattleProfile?: PlayerBattleProfile; // the match creator\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\n}\n\ninterface BattleLogEntry {\n RoundIndex?: number; // 1-based\n AttackerID?: string;\n DefenderID?: string;\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\n DefenderHpRemaining?: number; // floored at 0\n}\n\ninterface PlayerBattleProfile {\n UserID?: string;\n SelectedCharacterID?: string;\n SelectedCharacter?: CharacterModel; // see character-system skill\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\n Stats?: FighterStats; // final computed combat stats used for this fight\n}\n\ninterface FighterStats {\n MaxHp?: number; // starting HP, for a results-screen HP bar\n CurrentHp?: number; // HP at the end of the fight\n Damage?: number;\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\n CritChance?: number; // 0..MaxCritChance\n CritMultiplier?: number;\n Armor?: number; // flat damage reduction\n DodgeChance?: number; // 0..MaxDodgeChance\n}\n```\n\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\nhit drops the defender to 0 HP, the defender does not get to act that round.\n`AttackZone`/`DefenseZone` per log entry come from each side's\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\nstrategy shorter than the battle simply repeats from the top.\n\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\n\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\n3. `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\n\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\n\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\nlevel-scaling snapshot the engine used internally — the backend\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\nthat depends on it being populated.\n\n`FighterStats` is the resolved combat stats each fighter fought with — read\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\none fight, not a live/cached character stat.\n\n---\n\n## Config: MatchDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\n\n```ts\ninterface MatchDefinitions {\n InstantBattle?: InstantBattleDefinitions;\n}\n\ninterface InstantBattleDefinitions {\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\n Defaults?: InstantBattleSettings; // title-wide combat fallback\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\n}\n```\n\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\nthere's no other battle mode in the model today. If the title hasn't\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\n— so there is always at least one valid `RuleID` to pass.\n\nResolution order for every block is **rule's own → title `Defaults` (or\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\ninline-over-preset pattern the character module uses. `StatMapping` resolves\nper-field (each role can come from a different layer); `Combat`, `Entry`,\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\ntitle's `EntryDefaults`, even for fields it left unset).\n\n---\n\n## InstantBattleRule\n\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\n\n```ts\ninterface InstantBattleRule {\n RuleID?: string;\n DisplayName?: string;\n Description?: string;\n Economy?: MatchEconomySettings;\n Entry?: MatchEntrySettings;\n Creation?: MatchCreationSettings;\n Settings?: InstantBattleSettings;\n}\n\ninterface MatchEconomySettings {\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\n}\n```\n\n---\n\n## Combat formulas\n\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\nconfigures how a fighter's `FighterStats` are derived for a battle.\n\n```ts\ninterface InstantBattleSettings {\n StatMapping?: CombatStatMapping;\n Combat?: MatchCombatSettings;\n Formula?: MatchStatFormula;\n}\n\ninterface CombatStatMapping {\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\n DamageStatID?: string; // Default: \"Damage\"\n ArmorStatID?: string; // Default: \"Armor\"\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\n CritChanceStatID?: string; // Default: \"CritChance\"\n CritDamageStatID?: string; // Default: \"CritDamage\"\n DodgeStatID?: string; // Default: \"Speed\"\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\n}\n\ninterface MatchCombatSettings {\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\n MinHitDamage?: number; // floor for a hit after armor; default 1\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\n MaxCritChance?: number; // clamp; default 0.6\n MaxDodgeChance?: number; // clamp; default 0.4\n}\n\ninterface MatchStatFormula {\n Health?: CombatRoleFormula;\n Damage?: CombatRoleFormula;\n Armor?: CombatRoleFormula;\n AttackSpeed?: CombatRoleFormula;\n CritChance?: CombatRoleFormula;\n CritDamage?: CombatRoleFormula;\n Dodge?: CombatRoleFormula;\n}\n\ninterface CombatRoleFormula {\n Terms?: FormulaTerm[]; // the role's value = sum of terms\n}\n\ninterface FormulaTerm {\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\n Factors?: FormulaFactor[];\n}\n\ninterface FormulaFactor {\n Source?:\n | \"Constant\"\n | \"Stat\"\n | \"RankMultiplier\"\n | \"AllMight\"\n | \"GearFlat\"\n | \"GearPercent\";\n StatID?: string; // used for Stat/GearFlat/GearPercent; empty string for Stat = \"this role's own mapped stat\"\n Value?: number; // used for Constant\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\n}\n```\n\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\na concrete character `StatID` so the character's `StatLevels` (see\n`character-system` skill) feed into it — the same `StatID`s also key\nequipment flat/percent bonuses, so a remap automatically covers gear too.\nFactors reference base per-stat values and multipliers, never another role's\n_final_ value, so there are no formula cycles.\n\n**When a role has no custom formula** (`Formula` unset for that role), the\nengine falls back to its built-in default (backend\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\n\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight)\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\n\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\nscaling + character-rank scaling — see `character-system`\n`references/data-model.md`), `RankMultiplier` is the character's current\nrank's `GlobalStatMultiplier`, `AllMight` is the raw (un-offset) AllMight\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\nequipped-item bonuses for that `StatID` (scaled by the item instance's\nupgrade level). **This is config for building previews/tooltips, not\nsomething to execute client-side to predict a battle outcome** — the server\nevaluates it; treat any client-side evaluation as an estimate only.\n\n---\n\n## Entry & creation settings\n\n```ts\ninterface MatchEntrySettings {\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\n AllowEventTokens?: boolean; // default false\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\n}\n\ninterface EntryResourceRule {\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\n CatalogID?: string; // when Kind === \"Item\"\n ItemID?: string; // when Kind === \"Item\"\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\n MinAmount?: number; // 0 = no lower bound\n MaxAmount?: number; // 0 = no upper bound\n}\n\ninterface MatchCreationSettings {\n Cost?: ResourceConsume; // flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount)\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\n}\n```\n\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\noffer currencies/items/event tokens the rule permits, and clamp the amount\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\nas an entry.\"` regardless of policy) — refunding/awarding would have to\nrecreate the item instance and lose its upgrade level. Duplicate positions\n(same currency, or same catalog+item, or same event-token address) submitted\nin one `Entry` are merged server-side before validation, so you don't need to\ndedupe client-side.\n\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\n— both can be charged on creation (merged into one `Consume.Standard` charge),\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\ncreation time) control only the creation fee on cancel; the entry cost itself\nis always refunded on a successful cancel. The creation fee is **always**\nsunk once a match is actually played (win, loss, or draw), regardless of the\nrefund flag. Don't assume what was refunded — read it off\n`CancelMatchResponse.Resources`, which reflects what the server actually\nreturned.\n\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\n`createMatch` when targeting a specific opponent, and the authoritative check\non `instantBattle` (both directions of the pair, UTC calendar day, counting\n`Completed` matches) — a private challenge can still be rejected at battle\ntime even if it passed at creation time if the pair played other matches in\nbetween.\n\n---\n\n## Net reward / burn formula\n\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\neach of the loser's-and-winner's-combined entry positions and burns a share\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\n/ `CalculateNetReward`):\n\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\n rule's `Economy` is unset.\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\n exactly — no burn (items are indivisible; burning progress-style event\n tokens would be meaningless).\n\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\n\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\n\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\n leaves their balance and joins the pool); creator (winner) has\n `Grant.Standard = NetReward` (their own entry was already committed at\n `createMatch`, so only the reward is granted now).\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\n was already spent at `createMatch`, nothing more to take); joiner (winner)\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\n time) **and** `Grant.Standard = NetReward` in the same operation.\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\n never paid anything, so there's nothing to refund on their side. The\n creation fee is not refunded on a draw (it's sunk once played, per above).\n\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\nlogic branches on which one is present (see `MatchService.instantBattle` in\nSKILL.md's Gotchas).\n\n---\n\n## Battle strategy resolution\n\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\nwhichever side's profile is being built) resolve the strategy to use with the\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\n\n1. The `battleStrategy` passed in that specific request, if non-empty.\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\n non-empty.\n3. Otherwise a **freshly randomized** 3-step strategy (random\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\n — not persisted).\n\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\n\n---\n\n## Request shape\n\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\ninternally — useful context for reading error messages, not something you\nconstruct by hand:\n\n```ts\ninterface MatchRequest extends BaseRequest {\n MatchID?: string;\n TargetUserID?: string;\n Entry?: ResourceBundle;\n BattleStrategy?: BattleStepConfig[];\n CharacterID?: string;\n RuleID?: string;\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\n Page?: number;\n PageSize?: number;\n Statuses?: string[]; // GetMyMatches filter\n OnlyPublic?: boolean; // GetAvailableMatches filter\n}\n```\n\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\ncorrelation — informational, not something you need to read or set yourself.\n"
8
+ "content": "# Match data model — reference\n\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\nrequest/response types. All of these are **strictly typed in the SDK** —\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<MatchDefinitions>(\"Match\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\nTerminology: the backend consistently calls the cost to participate **Entry**\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\nin the Match model. Use that vocabulary in any UI copy you generate.\n\n## Contents\n\n- [Player state](#player-state) — `UserMatchState`\n- [Match (offer)](#match-offer) — `PvPMatch`\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\n- [InstantBattleRule](#instantbattlerule)\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\n- [Net reward / burn formula](#net-reward--burn-formula)\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\n- [Request shape](#request-shape)\n\n---\n\n## Player state\n\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\nhydrates it at login.\n\n```ts\ninterface UserMatchState {\n PvPBattleStrategy?: BattleStepConfig[];\n CreationLimits?: UserMatchCreationLimitState | null;\n}\n\ninterface UserMatchCreationLimitState {\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\n DailyResetUtc?: string; // next UTC midnight reset\n}\n\ninterface BattleStepConfig {\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\n}\n```\n\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\nserver-side (`Match.CreationLimits` on the player document, written by\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\nfor a client to eventually show \"next match available in…\" UI, but nothing in\n`MatchService` currently reads it back into this cache slot — treat it as\ninformational/future until a response actually populates it for you.\n\n---\n\n## Match (offer)\n\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\nand `UpdateMatchResponse`.\n\n```ts\ninterface PvPMatch {\n MatchID: string;\n TitleID?: string;\n RuleID?: string;\n CreatedAt?: string;\n CreatorID?: string;\n CreatorCharacterID?: string;\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\n TargetUserID?: string; // set = private/targeted challenge; absent = public\n Entry?: ResourceBundle; // the creator's entry cost\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\n RefundCreationCostOnCancel?: boolean;\n JoinedByUserID?: string;\n JoinedByCharacterID?: string;\n JoinedAt?: string;\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\n WinnerUserID?: string; // absent/null on a draw\n CompletedAt?: string;\n IsRewardDistributed?: boolean;\n RewardDistributedAt?: string;\n}\n```\n\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\nmatch) are tracked separately — cancelling refunds the entry cost always, and\nthe creation fee only when `RefundCreationCostOnCancel` is true.\n\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\nresolves the battle synchronously in the same call, so a match goes directly\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\nenum for forward-compat / other match modes, not for instant-battle.\n\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\nmatch's strategy from `getMyMatches` or after you've fetched the match some\nother way; it isn't needed to join, since your own strategy is what you send\nto `instantBattle`.\n\n---\n\n## Battle result\n\nReturned inside `InstantBattleResponse.Battle`.\n\n```ts\ninterface BattleResult {\n WinnerUserID?: string; // absent on a draw\n LoserUserID?: string; // absent on a draw\n Entry?: ResourceBundle; // one side's entry cost that was in play\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\n IsDraw?: boolean;\n P1BattleProfile?: PlayerBattleProfile; // the match creator\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\n}\n\ninterface BattleLogEntry {\n RoundIndex?: number; // 1-based\n AttackerID?: string;\n DefenderID?: string;\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\n DefenderHpRemaining?: number; // floored at 0\n}\n\ninterface PlayerBattleProfile {\n UserID?: string;\n SelectedCharacterID?: string;\n SelectedCharacter?: CharacterModel; // see character-system skill\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\n Stats?: FighterStats; // final computed combat stats used for this fight\n}\n\ninterface FighterStats {\n MaxHp?: number; // starting HP, for a results-screen HP bar\n CurrentHp?: number; // HP at the end of the fight\n Damage?: number;\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\n CritChance?: number; // 0..MaxCritChance\n CritMultiplier?: number;\n Armor?: number; // flat damage reduction\n DodgeChance?: number; // 0..MaxDodgeChance\n}\n```\n\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\nhit drops the defender to 0 HP, the defender does not get to act that round.\n`AttackZone`/`DefenseZone` per log entry come from each side's\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\nstrategy shorter than the battle simply repeats from the top.\n\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\n\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\n3. `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\n\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\n\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\nlevel-scaling snapshot the engine used internally — the backend\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\nthat depends on it being populated.\n\n`FighterStats` is the resolved combat stats each fighter fought with — read\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\none fight, not a live/cached character stat.\n\n---\n\n## Config: MatchDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\n\n```ts\ninterface MatchDefinitions {\n InstantBattle?: InstantBattleDefinitions;\n}\n\ninterface InstantBattleDefinitions {\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\n Defaults?: InstantBattleSettings; // title-wide combat fallback\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\n}\n```\n\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\nthere's no other battle mode in the model today. If the title hasn't\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\n— so there is always at least one valid `RuleID` to pass.\n\nResolution order for every block is **rule's own → title `Defaults` (or\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\ninline-over-preset pattern the character module uses. `StatMapping` resolves\nper-field (each role can come from a different layer); `Combat`, `Entry`,\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\ntitle's `EntryDefaults`, even for fields it left unset).\n\n---\n\n## InstantBattleRule\n\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\n\n```ts\ninterface InstantBattleRule {\n RuleID?: string;\n DisplayName?: string;\n Description?: string;\n Economy?: MatchEconomySettings;\n Entry?: MatchEntrySettings;\n Creation?: MatchCreationSettings;\n Settings?: InstantBattleSettings;\n}\n\ninterface MatchEconomySettings {\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\n}\n```\n\n---\n\n## Combat formulas\n\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\nconfigures how a fighter's `FighterStats` are derived for a battle.\n\n```ts\ninterface InstantBattleSettings {\n StatMapping?: CombatStatMapping;\n Combat?: MatchCombatSettings;\n Formula?: MatchStatFormula;\n}\n\ninterface CombatStatMapping {\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\n DamageStatID?: string; // Default: \"Damage\"\n ArmorStatID?: string; // Default: \"Armor\"\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\n CritChanceStatID?: string; // Default: \"CritChance\"\n CritDamageStatID?: string; // Default: \"CritDamage\"\n DodgeStatID?: string; // Default: \"Speed\"\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\n}\n\ninterface MatchCombatSettings {\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\n MinHitDamage?: number; // floor for a hit after armor; default 1\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\n MaxCritChance?: number; // clamp; default 0.6\n MaxDodgeChance?: number; // clamp; default 0.4\n}\n\ninterface MatchStatFormula {\n Health?: CombatRoleFormula;\n Damage?: CombatRoleFormula;\n Armor?: CombatRoleFormula;\n AttackSpeed?: CombatRoleFormula;\n CritChance?: CombatRoleFormula;\n CritDamage?: CombatRoleFormula;\n Dodge?: CombatRoleFormula;\n}\n\ninterface CombatRoleFormula {\n Terms?: FormulaTerm[]; // the role's value = sum of terms\n}\n\ninterface FormulaTerm {\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\n Factors?: FormulaFactor[];\n}\n\ninterface FormulaFactor {\n Source?:\n | \"Constant\"\n | \"Stat\"\n | \"RankMultiplier\"\n | \"AllMight\"\n | \"GearFlat\"\n | \"GearPercent\";\n StatID?: string; // used for Stat/GearFlat/GearPercent; empty string for Stat = \"this role's own mapped stat\"\n Value?: number; // used for Constant\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\n}\n```\n\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\na concrete character `StatID` so the character's `StatLevels` (see\n`character-system` skill) feed into it — the same `StatID`s also key\nequipment flat/percent bonuses, so a remap automatically covers gear too.\nFactors reference base per-stat values and multipliers, never another role's\n_final_ value, so there are no formula cycles.\n\n**When a role has no custom formula** (`Formula` unset for that role), the\nengine falls back to its built-in default (backend\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\n\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight)\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\n\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\nscaling + character-rank scaling — see `character-system`\n`references/data-model.md`), `RankMultiplier` is the character's current\nrank's `GlobalStatMultiplier`, `AllMight` is the raw (un-offset) AllMight\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\nequipped-item bonuses for that `StatID` (scaled by the item instance's\nupgrade level). **This is config for building previews/tooltips, not\nsomething to execute client-side to predict a battle outcome** — the server\nevaluates it; treat any client-side evaluation as an estimate only.\n\n---\n\n## Entry & creation settings\n\n```ts\ninterface MatchEntrySettings {\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\n AllowEventTokens?: boolean; // default false\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\n}\n\ninterface EntryResourceRule {\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\n CatalogID?: string; // when Kind === \"Item\"\n ItemID?: string; // when Kind === \"Item\"\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\n MinAmount?: number; // 0 = no lower bound\n MaxAmount?: number; // 0 = no upper bound\n}\n\ninterface MatchCreationSettings {\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\n}\n```\n\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\noffer currencies/items/event tokens the rule permits, and clamp the amount\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\nas an entry.\"` regardless of policy) — refunding/awarding would have to\nrecreate the item instance and lose its upgrade level. Duplicate positions\n(same currency, or same catalog+item, or same event-token address) submitted\nin one `Entry` are merged server-side before validation, so you don't need to\ndedupe client-side.\n\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\n— both can be charged on creation (merged into one `Consume.Standard` charge),\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\ncreation time) control only the creation fee on cancel; the entry cost itself\nis always refunded on a successful cancel. The creation fee is **always**\nsunk once a match is actually played (win, loss, or draw), regardless of the\nrefund flag. Don't assume what was refunded — read it off\n`CancelMatchResponse.Resources`, which reflects what the server actually\nreturned.\n\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\n`createMatch` when targeting a specific opponent, and the authoritative check\non `instantBattle` (both directions of the pair, UTC calendar day, counting\n`Completed` matches) — a private challenge can still be rejected at battle\ntime even if it passed at creation time if the pair played other matches in\nbetween.\n\n---\n\n## Net reward / burn formula\n\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\neach of the loser's-and-winner's-combined entry positions and burns a share\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\n/ `CalculateNetReward`):\n\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\n rule's `Economy` is unset.\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\n exactly — no burn (items are indivisible; burning progress-style event\n tokens would be meaningless).\n\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\n\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\n\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\n leaves their balance and joins the pool); creator (winner) has\n `Grant.Standard = NetReward` (their own entry was already committed at\n `createMatch`, so only the reward is granted now).\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\n was already spent at `createMatch`, nothing more to take); joiner (winner)\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\n time) **and** `Grant.Standard = NetReward` in the same operation.\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\n never paid anything, so there's nothing to refund on their side. The\n creation fee is not refunded on a draw (it's sunk once played, per above).\n\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\nlogic branches on which one is present (see `MatchService.instantBattle` in\nSKILL.md's Gotchas).\n\n---\n\n## Battle strategy resolution\n\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\nwhichever side's profile is being built) resolve the strategy to use with the\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\n\n1. The `battleStrategy` passed in that specific request, if non-empty.\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\n non-empty.\n3. Otherwise a **freshly randomized** 3-step strategy (random\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\n — not persisted).\n\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\n\n---\n\n## Request shape\n\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\ninternally — useful context for reading error messages, not something you\nconstruct by hand:\n\n```ts\ninterface MatchRequest extends BaseRequest {\n MatchID?: string;\n TargetUserID?: string;\n Entry?: ResourceBundle;\n BattleStrategy?: BattleStepConfig[];\n CharacterID?: string;\n RuleID?: string;\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\n Page?: number;\n PageSize?: number;\n Statuses?: string[]; // GetMyMatches filter\n OnlyPublic?: boolean; // GetAvailableMatches filter\n}\n```\n\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\ncorrelation — informational, not something you need to read or set yourself.\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "premium-system",
3
3
  "description": "Build a premium / subscription / VIP-tier system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.premium (PremiumService): load premium tier definitions, load the player's active subscriptions, activate a free trial, purchase a premium tier with virtual/item cost, or complete a real-money IAP subscription purchase (App Store / Google Play receipt validation). This is the player's subscription/IAP tier that other modules (Store cost discounts via ResourceConsume.PremiumDiscounts, reward/lootbox grant multipliers via ResourceGrant.PremiumTiers, segment gates via SegmentGate.MinPremiumTier) read to unlock perks. Use whenever the user wants a subscription/VIP/battle-pass-tier paywall, IAP receipt validation, trial flows, or touches client.premium, PremiumService, PremiumDefinition, MaxActiveTier, or premium discounts/multipliers — even if they don't name the module explicitly.",
4
- "content": "---\nname: premium-system\ndescription: >-\n Build a premium / subscription / VIP-tier system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.premium (PremiumService): load\n premium tier definitions, load the player's active subscriptions, activate a\n free trial, purchase a premium tier with virtual/item cost, or complete a\n real-money IAP subscription purchase (App Store / Google Play receipt\n validation). This is the player's subscription/IAP tier that other modules\n (Store cost discounts via ResourceConsume.PremiumDiscounts, reward/lootbox\n grant multipliers via ResourceGrant.PremiumTiers, segment gates via\n SegmentGate.MinPremiumTier) read to unlock perks. Use whenever the user wants\n a subscription/VIP/battle-pass-tier paywall, IAP receipt validation, trial\n flows, or touches client.premium, PremiumService, PremiumDefinition,\n MaxActiveTier, or premium discounts/multipliers — even if they don't name the\n module explicitly.\n---\n\n# Premium system (iDosGames TS SDK)\n\nThe Premium module is the title's **subscription / IAP tier** system: players\nactivate a trial or purchase a premium tier (with virtual currency, or —\nintended — real money via App Store / Google Play), and other modules read the\nplayer's active tier to unlock discounts, bonus multipliers, and gated\ncontent. Everything is **server-authoritative**: the client asks the backend\nto activate/purchase, the backend validates the transaction (cost, trial\neligibility, and — for real money — the receipt with Apple/Google), and the\nSDK mirrors the confirmed result into a local cache your UI reads. You never\nmutate premium state yourself — you call a method, check the result, and\nrender from the cache.\n\nThis skill is for **using** the production `PremiumService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(already trialed, receipt invalid, insufficient funds) — surface the error,\ndon't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n premium tiers: `Tier` number, `DurationDays`, `TrialDurationDays`, price\n options (virtual/item cost per option), App/Google product IDs, and\n free-form `Benefits` (display-only). Fetched with `getDefinitions()`.\n2. **User premium state** (state, per player) — this player's subscriptions\n (`Subscriptions`, keyed by `PremiumID`, including expired ones — they're\n never deleted), which trials they've already used (`ActivatedTrialIDs`,\n permanent), and their current best tier (`MaxActiveTier`). Fetched with\n `getUserState()`.\n\nA premium tier is identified by a string `PremiumID`. `Tier` is a plain number\n(higher = better) — it's what other modules compare against\n(`SegmentGate.MinPremiumTier`, `PremiumTierBundle.MinPremiumTier`) to decide\nwhether a perk applies; some gates instead pin an exact `RequiredPremiumID`,\nin which case the tier number is ignored. `MaxActiveTier` on the user state is\nthe number to read when you just need \"what's the player's current tier\"\nwithout walking `Subscriptions` yourself — the backend recalculates it from\nscratch (max `Tier` among non-expired subscriptions) on every read and write,\nso it self-heals even if a subscription lapsed since the last call.\n\nFor the full field-by-field shape, the trial-eligibility rules, tier\nresolution, and the purchase/renewal math, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (renewal countdowns, trial\neligibility, cost previews) off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst premium = client.premium; // the PremiumService\n```\n\nEvery premium method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing `TransactionID`/`ProductID`/`ReceiptData`),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window, default 600 ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Trial already used.\"`,\n`\"Subscription already active.\"`, `\"PriceOption not found\"`, insufficient\nfunds).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------- |\n| `getDefinitions()` | Load the title's premium tier catalog (config). | `PremiumDefinitionsResponse` (`Premium`) |\n| `getUserState()` | Load this player's subscriptions/trials/tier (state). | `PremiumStateResponse` (`Premium`) |\n| `activateTrial(premiumID, transactionID)` | Start a free trial of a tier (one-time per `PremiumID`, forever). | `PremiumPurchaseResponse` |\n| `purchaseItemOrCurrency(premiumID, transactionID, selectedOptionID?, count?)` | Buy/renew a tier with a virtual/item price option (default option `\"Default\"`, count 1). | `PremiumPurchaseResponse` |\n| `purchaseRealMoney(premiumID, transactionID, store, productID, receiptData, purchaseToken?, packageName?, appStoreEnvironment?)` | Send a real-money IAP receipt for validation. **Not implemented on the backend yet** — see Gotchas. | `PremiumPurchaseResponse` |\n\n`store` is `\"Apple\"` or `\"Google\"` (`StoreType`). `purchaseToken` is\nGoogle-specific (Play Billing purchase token); `packageName` and\n`appStoreEnvironment` are optional extra context some validators need. Every\npurchase/trial call requires a caller-supplied `transactionID` — treat it as\nthis attempt's client-side transaction id (distinct per attempt; the SDK also\nderives an internal idempotency key from it, and the backend independently\ntreats a repeated `transactionID` on an existing subscription as a no-charge\nreplay rather than a new purchase).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. The purchase methods also\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\n`packages/core/src/models/_shared/ResourceModels.ts`) to cached balances when\npresent; `activateTrial` always returns an empty `Resources` since a trial\ndoesn't move currency. Read updated tier/balances straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { PremiumDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<PremiumDefinitions>(\"Premium\");\nconst tier = defs?.Definitions?.[\"vip_gold\"];\ntier?.Tier; // numeric tier level\ntier?.PriceOptions; // { optionID: { RequiredResources: ResourceConsume } }\ntier?.Benefits; // free-form { key: stringified value } for display\n\n// User state (only present after getUserState() or a purchase/trial):\nconst p = client.data.user.state?.Premium;\np?.MaxActiveTier; // current best tier (number), self-healing on every fetch\np?.Subscriptions?.[\"vip_gold\"]?.ExpirationDate; // check this, not just key existence\np?.ActivatedTrialIDs; // trials already used, ever — don't offer them again\n```\n\n`applyPremium` fully **replaces** the cached `Premium` object on every write\n(`packages/core/src/cache/UserData.ts:672`) — it's not a deep merge, so a\n`getUserState()`/purchase/trial response always reflects the complete,\nauthoritative state.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `premium:definitionsLoaded` → `PremiumDefinitionsResponse`\n- `premium:stateLoaded` → `PremiumStateResponse`\n- `premium:trialActivated` → `PremiumPurchaseResponse`\n- `premium:purchaseCompleted` → `PremiumPurchaseResponse`\n- `premium:realMoneyPurchaseCompleted` → `PremiumPurchaseResponse`\n\nThe coarse `user:premiumUpdated` (and `user:anyUpdated`) also fire on any\npremium cache write — handy for a \"re-render everything\" hook, and useful for\nany other screen that displays a tier-gated perk.\n\n```ts\nconst off = client.on(\"premium:purchaseCompleted\", (r) => {\n console.log(`Now on ${r.Subscription?.PremiumID}, tier state refreshed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show tiers and the player's current status\n\n```ts\nawait client.premium.getDefinitions();\nawait client.premium.getUserState();\n\nconst defs = client.data.config.getSection<PremiumDefinitions>(\"Premium\");\nconst state = client.data.user.state?.Premium;\n\nfor (const [premiumID, tier] of Object.entries(defs?.Definitions ?? {})) {\n const active = state?.Subscriptions?.[premiumID];\n const isActive =\n !!active?.ExpirationDate && new Date(active.ExpirationDate) > new Date();\n const canTrial =\n (tier.TrialDurationDays ?? 0) > 0 &&\n !state?.ActivatedTrialIDs?.includes(premiumID);\n // isActive drives \"renews on\"/\"expired\" copy;\n // canTrial drives whether to show a \"Start free trial\" button.\n}\n```\n\n### Activate a free trial\n\n```ts\nconst res = await client.premium.activateTrial(\"vip_gold\", crypto.randomUUID());\nif (!res.ok) return showError(res.error); // e.g. \"Trial already used.\"\nres.data.Subscription?.ExpirationDate; // when the trial ends\n```\n\nOnly tiers with `TrialDurationDays > 0` support this — otherwise the backend\nrejects with `\"Trial is not available for this premium.\"` A trial also can't\nbe started on top of an already-active subscription to the same tier\n(`\"Subscription already active.\"`).\n\n### Purchase a tier with virtual currency\n\n```ts\nconst res = await client.premium.purchaseItemOrCurrency(\n \"vip_gold\",\n crypto.randomUUID(),\n \"Default\",\n 1,\n);\nif (!res.ok) return showError(res.error); // e.g. \"PriceOption not found\", insufficient funds\n// cache now has the new/renewed subscription + debited balances.\n```\n\nIf the player already has this exact tier active, the purchase **extends**\nits `ExpirationDate` rather than starting a fresh countdown or stacking a\nsecond entry — see\n[the renewal rule](references/data-model.md#purchase-with-virtual-currency--items).\nThe chosen `selectedOptionID` must exist in that tier's `PriceOptions`, and\nthat option must carry a non-empty virtual cost — an option with no\n`RequiredResources` is reserved for the real-money flow and this call rejects\nit.\n\n### Complete a real-money purchase (App Store / Google Play) — not yet backed\n\n```ts\n// After your platform IAP SDK confirms the purchase and hands you a receipt:\nconst res = await client.premium.purchaseRealMoney(\n \"vip_gold\",\n crypto.randomUUID(),\n \"Apple\",\n \"com.yourgame.vip_gold_monthly\",\n receiptData, // base64 receipt / signed transaction payload\n);\nif (!res.ok) return showError(res.error);\n```\n\nThe method, request shape, and events are implemented client-side, but the\ncurrent backend has **no handler** for this action — calling it returns\n`reason: \"server\", error: \"Action not implemented\"` (see Gotchas below and\n[the reference](references/data-model.md#real-money-iap-purchase--current-backend-status)\nfor exactly why). Do not wire this into a shipping purchase button yet; treat\nit as a documented but currently-nonfunctional call.\n\n### Read the tier elsewhere (discounts/multipliers)\n\nOther modules don't expose a \"premium\" parameter — they read the cached tier\nthemselves server-side when computing `ResourceConsume.PremiumDiscounts` /\n`ResourceGrant.PremiumTiers` / `SegmentGate.MinPremiumTier`. Client-side, use\n`MaxActiveTier` only to preview/gray-out UI (e.g. \"Requires VIP Gold+\" on a\nstore offer or reward tier); the actual discount/bonus is applied by the\nbackend and arrives in that call's own `Resources`.\n\n```ts\nconst myTier = client.data.user.state?.Premium?.MaxActiveTier ?? 0;\nconst locked = (bundle.MinPremiumTier ?? 0) > myTier;\n```\n\n## Gotchas\n\n- **`purchaseRealMoney` is not implemented on the backend.** The v2\n `Premium.cs` HTTP handler's action switch only has cases for\n `GetDefinitions`, `GetUserState`, `ActivateTrial`, and\n `PurchaseWithResources` — `PurchaseRealMoney` falls through to\n `default: \"Action not implemented\"`\n (`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:52-69`). Real-money receipt\n validation exists in the backend only under the legacy **v1** surface\n (`ValidateIAP.cs`/`ValidateIAPSubscription.cs`), which is a separate\n endpoint family not reachable through `client.premium`. Don't build a\n shipping IAP-subscription flow on this call until the backend adds a real\n handler.\n- **`ActivatedTrialIDs` is permanent and never cleared.** A trial is one-time\n per `PremiumID` per account for the lifetime of the account — cancelling,\n expiring, or unsubscribing doesn't remove the id. Check it client-side\n before showing a trial CTA rather than relying only on the server\n rejection (`\"Trial already used.\"`).\n- **An entry in `Subscriptions` isn't necessarily active.** Expired\n subscriptions are kept, not deleted (they support renewal-on-top-of-lapse\n math and history). Always check `ExpirationDate`, or just trust\n `MaxActiveTier`, which already excludes expired/unknown tiers.\n- **Tiers take the max, they don't stack.** Holding two active subscriptions\n at once doesn't add their `Tier`s — `MaxActiveTier` is simply the highest\n `Tier` among currently-active subscriptions.\n- **Buying an already-active tier extends it, it doesn't restart it.** The\n new duration is added on top of the existing `ExpirationDate`; buying tier\n X again while X is still active is a renewal, not a discard-and-replace.\n- **Guard against double-submit.** Each call needs a caller-supplied\n `transactionID`; reusing the same one for a retry is fine (that's what it's\n for — both the trial and purchase endpoints detect a matching\n `TransactionID` on the existing subscription and return the current state\n with no new charge), but a double-clicked \"Subscribe\" with two _different_\n generated ids is two real attempts. Disable the control while a call is in\n flight. Firing the same endpoint again within the client throttle window\n (default 600 ms) is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.\n- **`Resources` is nullish only in shape, not in practice.** Trial responses\n and idempotent-replay responses always come back with an explicit empty\n `ResourceOperation` (never `null`) — but the type is still nullish, so\n guard before reading into it anyway.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the exact tier-resolution algorithm, trial eligibility rules,\nthe purchase/renewal math, verbatim backend rejection strings, the current\nreal-money-purchase gap, and how other modules' gate/discount/multiplier\ntypes reference Premium's `Tier`/`MaxActiveTier`. Read it when building\nconfig-driven UI (renewal countdowns, trial eligibility, cost previews) or\nwhen an error message points at a rule you need to understand.\n",
4
+ "content": "---\nname: premium-system\ndescription: >-\n Build a premium / subscription / VIP-tier system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.premium (PremiumService): load\n premium tier definitions, load the player's active subscriptions, activate a\n free trial, purchase a premium tier with virtual/item cost, or complete a\n real-money IAP subscription purchase (App Store / Google Play receipt\n validation). This is the player's subscription/IAP tier that other modules\n (Store cost discounts via ResourceConsume.PremiumDiscounts, reward/lootbox\n grant multipliers via ResourceGrant.PremiumTiers, segment gates via\n SegmentGate.MinPremiumTier) read to unlock perks. Use whenever the user wants\n a subscription/VIP/battle-pass-tier paywall, IAP receipt validation, trial\n flows, or touches client.premium, PremiumService, PremiumDefinition,\n MaxActiveTier, or premium discounts/multipliers — even if they don't name the\n module explicitly.\n---\n\n# Premium system (iDosGames TS SDK)\n\nThe Premium module is the title's **subscription / IAP tier** system: players\nactivate a trial or purchase a premium tier (with virtual currency, or —\nintended — real money via App Store / Google Play), and other modules read the\nplayer's active tier to unlock discounts, bonus multipliers, and gated\ncontent. Everything is **server-authoritative**: the client asks the backend\nto activate/purchase, the backend validates the transaction (cost, trial\neligibility, and — for real money — the receipt with Apple/Google), and the\nSDK mirrors the confirmed result into a local cache your UI reads. You never\nmutate premium state yourself — you call a method, check the result, and\nrender from the cache.\n\nThis skill is for **using** the production `PremiumService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(already trialed, receipt invalid, insufficient funds) — surface the error,\ndon't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n premium tiers: `Tier` number, `DurationDays`, `TrialDurationDays`, price\n options (virtual/item cost per option), App/Google product IDs, and\n free-form `Benefits` (display-only). Fetched with `getDefinitions()`.\n2. **User premium state** (state, per player) — this player's subscriptions\n (`Subscriptions`, keyed by `PremiumID`, including expired ones — they're\n never deleted), which trials they've already used (`ActivatedTrialIDs`,\n permanent), and their current best tier (`MaxActiveTier`). Fetched with\n `getUserState()`.\n\nA premium tier is identified by a string `PremiumID`. `Tier` is a plain number\n(higher = better) — it's what other modules compare against\n(`SegmentGate.MinPremiumTier`, `PremiumTierBundle.MinPremiumTier`) to decide\nwhether a perk applies; some gates instead pin an exact `RequiredPremiumID`,\nin which case the tier number is ignored. `MaxActiveTier` on the user state is\nthe number to read when you just need \"what's the player's current tier\"\nwithout walking `Subscriptions` yourself — the backend recalculates it from\nscratch (max `Tier` among non-expired subscriptions) on every read and write,\nso it self-heals even if a subscription lapsed since the last call.\n\nFor the full field-by-field shape, the trial-eligibility rules, tier\nresolution, and the purchase/renewal math, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (renewal countdowns, trial\neligibility, cost previews) off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst premium = client.premium; // the PremiumService\n```\n\nEvery premium method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing `TransactionID`/`ProductID`/`ReceiptData`),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window, default 600 ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Trial already used.\"`,\n`\"Subscription already active.\"`, `\"PriceOption not found\"`, insufficient\nfunds).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------- |\n| `getDefinitions()` | Load the title's premium tier catalog (config). | `PremiumDefinitionsResponse` (`Premium`) |\n| `getUserState()` | Load this player's subscriptions/trials/tier (state). | `PremiumStateResponse` (`Premium`) |\n| `activateTrial(premiumID, transactionID)` | Start a free trial of a tier (one-time per `PremiumID`, forever). | `PremiumPurchaseResponse` |\n| `purchaseItemOrCurrency(premiumID, transactionID, selectedOptionID?, count?)` | Buy/renew a tier with a virtual/item price option (default option `\"Default\"`, count 1). | `PremiumPurchaseResponse` |\n| `purchaseRealMoney(premiumID, transactionID, store, productID, receiptData, purchaseToken?, packageName?, appStoreEnvironment?)` | Send a real-money IAP receipt for validation. **Not implemented on the backend yet** — see Gotchas. | `PremiumPurchaseResponse` |\n\n`store` is `\"Apple\"` or `\"Google\"` (`StoreType`). `purchaseToken` is\nGoogle-specific (Play Billing purchase token); `packageName` and\n`appStoreEnvironment` are optional extra context some validators need. Every\npurchase/trial call requires a caller-supplied `transactionID` — treat it as\nthis attempt's client-side transaction id (distinct per attempt; the SDK also\nderives an internal idempotency key from it, and the backend independently\ntreats a repeated `transactionID` on an existing subscription as a no-charge\nreplay rather than a new purchase).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. The purchase methods also\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\n`packages/core/src/models/_shared/ResourceModels.ts`) to cached balances when\npresent; `activateTrial` always returns an empty `Resources` since a trial\ndoesn't move currency. Read updated tier/balances straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { PremiumDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<PremiumDefinitions>(\"Premium\");\nconst tier = defs?.Definitions?.[\"vip_gold\"];\ntier?.Tier; // numeric tier level\ntier?.PriceOptions; // { OptionID: { OptionID, Name, Cost, AllowedPlatforms } }\ntier?.Benefits; // free-form { key: stringified value } for display\n\n// User state (only present after getUserState() or a purchase/trial):\nconst p = client.data.user.state?.Premium;\np?.MaxActiveTier; // current best tier (number), self-healing on every fetch\np?.Subscriptions?.[\"vip_gold\"]?.ExpirationDate; // check this, not just key existence\np?.ActivatedTrialIDs; // trials already used, ever — don't offer them again\n```\n\n`applyPremium` fully **replaces** the cached `Premium` object on every write\n(`packages/core/src/cache/UserData.ts:672`) — it's not a deep merge, so a\n`getUserState()`/purchase/trial response always reflects the complete,\nauthoritative state.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `premium:definitionsLoaded` → `PremiumDefinitionsResponse`\n- `premium:stateLoaded` → `PremiumStateResponse`\n- `premium:trialActivated` → `PremiumPurchaseResponse`\n- `premium:purchaseCompleted` → `PremiumPurchaseResponse`\n- `premium:realMoneyPurchaseCompleted` → `PremiumPurchaseResponse`\n\nThe coarse `user:premiumUpdated` (and `user:anyUpdated`) also fire on any\npremium cache write — handy for a \"re-render everything\" hook, and useful for\nany other screen that displays a tier-gated perk.\n\n```ts\nconst off = client.on(\"premium:purchaseCompleted\", (r) => {\n console.log(`Now on ${r.Subscription?.PremiumID}, tier state refreshed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show tiers and the player's current status\n\n```ts\nawait client.premium.getDefinitions();\nawait client.premium.getUserState();\n\nconst defs = client.data.config.getSection<PremiumDefinitions>(\"Premium\");\nconst state = client.data.user.state?.Premium;\n\nfor (const [premiumID, tier] of Object.entries(defs?.Definitions ?? {})) {\n const active = state?.Subscriptions?.[premiumID];\n const isActive =\n !!active?.ExpirationDate && new Date(active.ExpirationDate) > new Date();\n const canTrial =\n (tier.TrialDurationDays ?? 0) > 0 &&\n !state?.ActivatedTrialIDs?.includes(premiumID);\n // isActive drives \"renews on\"/\"expired\" copy;\n // canTrial drives whether to show a \"Start free trial\" button.\n}\n```\n\n### Activate a free trial\n\n```ts\nconst res = await client.premium.activateTrial(\"vip_gold\", crypto.randomUUID());\nif (!res.ok) return showError(res.error); // e.g. \"Trial already used.\"\nres.data.Subscription?.ExpirationDate; // when the trial ends\n```\n\nOnly tiers with `TrialDurationDays > 0` support this — otherwise the backend\nrejects with `\"Trial is not available for this premium.\"` A trial also can't\nbe started on top of an already-active subscription to the same tier\n(`\"Subscription already active.\"`).\n\n### Purchase a tier with virtual currency\n\n```ts\nconst res = await client.premium.purchaseItemOrCurrency(\n \"vip_gold\",\n crypto.randomUUID(),\n \"Default\",\n 1,\n);\nif (!res.ok) return showError(res.error); // e.g. \"PriceOption not found\", insufficient funds\n// cache now has the new/renewed subscription + debited balances.\n```\n\nIf the player already has this exact tier active, the purchase **extends**\nits `ExpirationDate` rather than starting a fresh countdown or stacking a\nsecond entry — see\n[the renewal rule](references/data-model.md#purchase-with-virtual-currency--items).\nThe chosen `selectedOptionID` must exist in that tier's `PriceOptions`, and\nthat option must carry a non-empty virtual cost — an option with an empty `Cost`,\nor one paid in a store (a `Purchase` entry), belongs to the real-money flow and\nthis call rejects it.\n\n### Complete a real-money purchase (App Store / Google Play) — not yet backed\n\n```ts\n// After your platform IAP SDK confirms the purchase and hands you a receipt:\nconst res = await client.premium.purchaseRealMoney(\n \"vip_gold\",\n crypto.randomUUID(),\n \"Apple\",\n \"com.yourgame.vip_gold_monthly\",\n receiptData, // base64 receipt / signed transaction payload\n);\nif (!res.ok) return showError(res.error);\n```\n\nThe method, request shape, and events are implemented client-side, but the\ncurrent backend has **no handler** for this action — calling it returns\n`reason: \"server\", error: \"Action not implemented\"` (see Gotchas below and\n[the reference](references/data-model.md#real-money-iap-purchase--current-backend-status)\nfor exactly why). Do not wire this into a shipping purchase button yet; treat\nit as a documented but currently-nonfunctional call.\n\n### Read the tier elsewhere (discounts/multipliers)\n\nOther modules don't expose a \"premium\" parameter — they read the cached tier\nthemselves server-side when computing `ResourceConsume.PremiumDiscounts` /\n`ResourceGrant.PremiumTiers` / `SegmentGate.MinPremiumTier`. Client-side, use\n`MaxActiveTier` only to preview/gray-out UI (e.g. \"Requires VIP Gold+\" on a\nstore offer or reward tier); the actual discount/bonus is applied by the\nbackend and arrives in that call's own `Resources`.\n\n```ts\nconst myTier = client.data.user.state?.Premium?.MaxActiveTier ?? 0;\nconst locked = (bundle.MinPremiumTier ?? 0) > myTier;\n```\n\n## Gotchas\n\n- **`purchaseRealMoney` is not implemented on the backend.** The v2\n `Premium.cs` HTTP handler's action switch only has cases for\n `GetDefinitions`, `GetUserState`, `ActivateTrial`, and\n `PurchaseWithResources` — `PurchaseRealMoney` falls through to\n `default: \"Action not implemented\"`\n (`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:52-69`). Real-money receipt\n validation exists in the backend only under the legacy **v1** surface\n (`ValidateIAP.cs`/`ValidateIAPSubscription.cs`), which is a separate\n endpoint family not reachable through `client.premium`. Don't build a\n shipping IAP-subscription flow on this call until the backend adds a real\n handler.\n- **`ActivatedTrialIDs` is permanent and never cleared.** A trial is one-time\n per `PremiumID` per account for the lifetime of the account — cancelling,\n expiring, or unsubscribing doesn't remove the id. Check it client-side\n before showing a trial CTA rather than relying only on the server\n rejection (`\"Trial already used.\"`).\n- **An entry in `Subscriptions` isn't necessarily active.** Expired\n subscriptions are kept, not deleted (they support renewal-on-top-of-lapse\n math and history). Always check `ExpirationDate`, or just trust\n `MaxActiveTier`, which already excludes expired/unknown tiers.\n- **Tiers take the max, they don't stack.** Holding two active subscriptions\n at once doesn't add their `Tier`s — `MaxActiveTier` is simply the highest\n `Tier` among currently-active subscriptions.\n- **Buying an already-active tier extends it, it doesn't restart it.** The\n new duration is added on top of the existing `ExpirationDate`; buying tier\n X again while X is still active is a renewal, not a discard-and-replace.\n- **Guard against double-submit.** Each call needs a caller-supplied\n `transactionID`; reusing the same one for a retry is fine (that's what it's\n for — both the trial and purchase endpoints detect a matching\n `TransactionID` on the existing subscription and return the current state\n with no new charge), but a double-clicked \"Subscribe\" with two _different_\n generated ids is two real attempts. Disable the control while a call is in\n flight. Firing the same endpoint again within the client throttle window\n (default 600 ms) is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.\n- **`Resources` is nullish only in shape, not in practice.** Trial responses\n and idempotent-replay responses always come back with an explicit empty\n `ResourceOperation` (never `null`) — but the type is still nullish, so\n guard before reading into it anyway.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the exact tier-resolution algorithm, trial eligibility rules,\nthe purchase/renewal math, verbatim backend rejection strings, the current\nreal-money-purchase gap, and how other modules' gate/discount/multiplier\ntypes reference Premium's `Tier`/`MaxActiveTier`. Read it when building\nconfig-driven UI (renewal countdowns, trial eligibility, cost previews) or\nwhen an error message points at a rule you need to understand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Premium data model — reference\n\nFull shape of the config (Definitions) and player state, the tier-resolution\nand trial rules the backend enforces, and the purchase/receipt flow. All of\nthese are **strictly typed in the SDK** — `PremiumDefinitions` and its nested\nblocks (`PremiumDefinition`, `PremiumPriceOption`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<PremiumDefinitions>(\"Premium\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: PremiumDefinitions](#config-premiumdefinitions) — what `getDefinitions()` returns\n- [PremiumDefinition](#premiumdefinition)\n- [PremiumPriceOption](#premiumpriceoption)\n- [Tier resolution (MaxActiveTier)](#tier-resolution-maxactivetier)\n- [Trial rules](#trial-rules)\n- [Purchase with virtual currency / items](#purchase-with-virtual-currency--items)\n- [Real-money IAP purchase — current backend status](#real-money-iap-purchase--current-backend-status)\n- [How other modules read a player's tier](#how-other-modules-read-a-players-tier)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Premium: UserPremiumState }` and cached at\n`client.data.user.state?.Premium` (full replace on every write — see\n`applyPremium` in `packages/core/src/cache/UserData.ts:672`).\n\n```ts\ninterface UserPremiumState {\n Subscriptions?: Record<string, PremiumSubscription>; // key = PremiumID\n ActivatedTrialIDs?: string[]; // PremiumIDs already trialed — permanent, one-shot\n MaxActiveTier?: number; // highest Tier among currently-active subscriptions\n}\n\ninterface PremiumSubscription {\n PremiumID?: string;\n PurchaseDate?: string; // ISO; set on first purchase, or on renewal after a full lapse\n ExpirationDate?: string; // ISO (UTC); subscription is \"active\" iff this is strictly in the future\n TransactionID?: string; // last transaction that touched this subscription (idempotency key)\n IsAutoRenewEnabled?: boolean; // always false for trial/virtual purchases — see below\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/UserPremiumState.cs:13-40`.\n\nA subscription entry existing in `Subscriptions` does **not** mean it's\nactive — always compare `ExpirationDate` to \"now\" (or just trust\n`MaxActiveTier`, which the backend already recalculates for you on every\nread/write). Expired entries are never deleted; they're left in place so\n`ActivatedTrialIDs`-style history and renewal-on-top-of-lapsed logic keep\nworking. Don't build \"is subscribed\" UI off `Subscriptions[id]` existing —\ncheck its `ExpirationDate`, or better, read `MaxActiveTier`.\n\n---\n\n## Config: PremiumDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<PremiumDefinitions>(\"Premium\")`.\n\n```ts\ninterface PremiumDefinitions {\n Definitions?: Record<string, PremiumDefinition>; // key = PremiumID\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:20-26`.\n\n---\n\n## PremiumDefinition\n\nSelf-contained template for one subscription / premium pass / VIP tier.\n\n```ts\ninterface PremiumDefinition {\n PremiumID?: string; // stable id, e.g. \"silver_vip\" — never renamed after publish\n DisplayName?: string;\n Tier?: number; // 1, 2, 3... higher = more premium; compared against MinPremiumTier gates\n DurationDays?: number; // subscription length; 0 = permanent, 30 = monthly, 365 = yearly\n TrialDurationDays?: number; // 0 = no trial available for this tier\n PriceOptions?: Record<string, PremiumPriceOption>; // key = OptionID, e.g. \"Default\"\n AppleProductID?: string; // empty = not sold via App Store\n GoogleProductID?: string; // empty = not sold via Google Play\n Benefits?: Record<string, string>; // free-form slug -> stringified numeric param, for display only\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:35-108`.\n\n- **`Tier`** is the number every other module's gate compares against\n (`SegmentGate.MinPremiumTier`, `ResourceConsume.PremiumTiers` /\n `ResourceGrant.PremiumTiers` entries' `MinPremiumTier`, and any\n `RequiredPremiumID` variants of the same gate — see\n [How other modules read a player's tier](#how-other-modules-read-a-players-tier)).\n- **`DurationDays: 0`** means \"permanent\" — the backend actually implements\n this as expiring **100 years** from purchase (`ComputePurchase`,\n `IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:211`:\n `now.AddYears(100)`), not a literal null-expiration sentinel. Treat any\n `ExpirationDate` more than a few decades out as \"effectively permanent\" in\n UI, but don't special-case `0`/`null` yourself — always compare the actual\n `ExpirationDate`.\n- **`Benefits`** is display-only free-form data (e.g. `\"ExpMult\": \"1.2\"`,\n `\"NoAds\": \"1.0\"`). The SDK does not interpret these keys — a title defines\n its own vocabulary and its own game code reads them for copy/UI. They are\n **not** the mechanism that actually grants discounts/multipliers/gates —\n those are wired up server-side through `ResourceConsume.PremiumDiscounts` /\n `PremiumTiers`, `ResourceGrant.PremiumTiers`, and `SegmentGate.MinPremiumTier`\n independently of `Benefits`.\n\n---\n\n## PremiumPriceOption\n\nOne payment option within a `PremiumDefinition.PriceOptions` map.\n\n```ts\ninterface PremiumPriceOption {\n OptionID?: string; // key within PriceOptions, e.g. \"Default\", \"bundle_a\"\n Name?: string; // optional display name, e.g. \"For Gold\"\n RequiredResources?: ResourceConsume; // debit-only cost; see below\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:117-140`.\n\n`RequiredResources` is a standard `ResourceConsume`\n(`packages/core/src/models/_shared/ResourceModels.ts`) — cost lives in\n`RequiredResources.Standard.Entries` (items/currencies) and/or\n`RequiredResources.Standard.EventTokens`. **`purchaseItemOrCurrency` requires\nat least one of those two to be non-empty** — the backend rejects the call\noutright with `\"This purchase option has no RequiredResources. Real-money\nflow is not supported by this endpoint.\"` if both are empty (this is how the\nserver tells apart a virtual-cost option from a real-money-only one; see\n[Real-money IAP purchase](#real-money-iap-purchase--current-backend-status)).\n`RequiredResources` may also declare `PremiumDiscounts` — if present, the\nbackend auto-applies the player's own best tier discount when charging, so\nthe amount actually debited can be lower than the raw `Amount` shown in the\noption (same mechanism documented in character-system's stat-cost formulas).\n\nSource of the rejection string: `IDosGamesSDK/API/Client/v2/Premium/Premium.cs:268`.\n\n---\n\n## Tier resolution (MaxActiveTier)\n\n`MaxActiveTier` is **not** stored independently — it's recomputed by\n`PremiumHelpers.RecalculateMaxTier` every time subscriptions change or are\nread (`IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:122-144`):\n\n1. Walk every entry in `Subscriptions`.\n2. Skip any whose `ExpirationDate <= now` (UTC) — expired subscriptions are\n silently ignored, never physically removed.\n3. Skip any `PremiumID` no longer present in the title's `Definitions` (a\n tier that was deleted/renamed from config after the player subscribed).\n4. `MaxActiveTier` = the highest `Tier` among what's left; `0` if nothing\n qualifies.\n\nThis runs on `GetUserState`, `ActivateTrial`, and\n`PurchaseWithResources` — so `MaxActiveTier` is always self-healing: even if\nsubscriptions expire between calls, the very next `getUserState()` (or any\npurchase/trial call) corrects it and persists the correction\n(`NormalizePremiumState`, `IDosGamesSDK/API/Client/v2/Premium/Premium.cs:391-409`).\n**Tiers don't stack** — holding two active subscriptions doesn't add their\ntiers together, it just takes the max.\n\nA separate helper, `PremiumHelpers.HasRequiredPremium`\n(`IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:44-55`), is\nwhat other modules' gate checks actually call server-side:\n\n- If a gate specifies a `RequiredPremiumID`, it checks only whether that\n exact `PremiumID` has an active subscription — **the tier number is\n ignored** in this branch (owning a specific pass matters, not its rank).\n- Otherwise, if the gate specifies `MinPremiumTier > 0`, it checks\n `MaxActiveTier >= MinPremiumTier`.\n- If neither is specified, the gate passes for everyone.\n\nThis is why `SegmentGate` and the resource-bundle gate types below expose\n**both** `MinPremiumTier` and `RequiredPremiumID`/`RequiredPremiumIDs` —\ntitles choose per-gate whether \"any tier ≥ N\" or \"must own this exact pass\"\nis the right check.\n\n---\n\n## Trial rules\n\n`activateTrial(premiumID, transactionID)` → backend `ActivateTrial`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:140-238`). Checks, in order:\n\n1. `PremiumID` must be a safe Mongo key (no `.` or `$`) — else\n `\"Invalid PremiumID\"`.\n2. `TransactionID` is required — else `\"TransactionID is required\"`.\n3. The tier's definition must exist — else `\"Premium definition not found\"`.\n4. **`TrialDurationDays` must be `> 0`** — else\n `\"Trial is not available for this premium.\"` Not every tier offers a\n trial; check `TrialDurationDays` before showing a trial CTA.\n5. **Idempotent replay**: if the player already has a `Subscriptions[premiumID]`\n entry whose `TransactionID` matches the one just sent, the call returns\n the existing subscription unchanged (no new trial, no error) — this is\n what makes retrying a dropped request safe.\n6. **One trial per `PremiumID` per account, forever**: if `premiumID` is\n already in `ActivatedTrialIDs`, the call fails with\n `\"Trial already used.\"` This list is never cleared — cancelling a trial,\n letting it expire, or unsubscribing does not remove the id, so a player\n can never get a second free trial of the same tier from this endpoint.\n7. If the player has a _currently active_ (non-expired) subscription to that\n same `PremiumID` already, the call fails with\n `\"Subscription already active.\"` — you can't \"trial\" on top of an\n existing live subscription.\n8. On success: a new `PremiumSubscription` is created with\n `ExpirationDate = now + TrialDurationDays`, `IsAutoRenewEnabled: false`,\n `premiumID` is appended to `ActivatedTrialIDs`, and `MaxActiveTier` is\n recalculated. **No resources are consumed or granted** —\n `PremiumPurchaseResponse.Resources` comes back as an empty\n `ResourceOperation` (`Resources: new()`), never `null`, for this call.\n\nExact rejection strings (verbatim, from `Premium.cs`):\n`\"Invalid PremiumID\"` (line 149), `\"TransactionID is required\"` (line 150),\n`\"Premium definition not found\"` (line 154),\n`\"Trial is not available for this premium.\"` (line 156),\n`\"Trial already used.\"` (line 184),\n`\"Subscription already active.\"` (line 189),\n`\"User not found\"` (line 164), `\"Database update failed\"` (line 224).\n\n---\n\n## Purchase with virtual currency / items\n\n`purchaseItemOrCurrency(premiumID, transactionID, selectedOptionID, count)` →\nbackend `PurchaseWithResources`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:240-389`, pure calc in\n`PremiumHelpers.ComputePurchase`, lines 180-255). Checks and behavior, in\norder:\n\n1. `PremiumID` safe-key check, `TransactionID` required — same errors as\n trial.\n2. Definition must exist (`\"Premium definition not found\"`) and\n `selectedOptionID` (default `\"Default\"`) must resolve to a configured\n `PriceOptions` entry (`\"PriceOption not found\"`).\n3. The option's `RequiredResources` must carry at least one item/currency\n entry or event-token entry — otherwise\n `\"This purchase option has no RequiredResources. Real-money flow is not\nsupported by this endpoint.\"` (this endpoint is virtual-cost only; see\n next section for real money).\n4. **Idempotent replay** (`ComputePurchase`): if `Subscriptions[premiumID]`\n already has this exact `TransactionID`, the call returns the current\n state with **no charge** — safe retry.\n5. **Renewal stacking, not tier stacking**: if the player already has an\n active (non-expired) subscription to the _same_ `PremiumID`, the new\n duration is added **on top of** the existing `ExpirationDate` rather than\n from `now` (`baseTime = existingSub.ExpirationDate` when it's still in the\n future). Buying tier X while X is already active extends it; it does not\n reset the clock or double-grant. `PurchaseDate` is only updated when\n there was no prior subscription or the prior one had fully expired.\n6. `count` (default 1, clamped to minimum 1) multiplies `DurationDays` when\n computing the new expiration (`baseTime.AddDays(DurationDays * count)`) —\n there's no separate \"quantity\" concept beyond stretching the duration.\n `DurationDays <= 0` still resolves to the fixed `+100 years`, ignoring\n `count`.\n7. **Charge and write are atomic together**: the resource debit\n (`RequiredResources`, with the player's own `PremiumDiscounts` applied\n automatically if configured) and the subscription write happen in the\n same `ResourceService.ApplyResourceOperationAtomicAsync` call, guarded\n additionally by a Mongo filter that rejects the write if a subscription\n with this `TransactionID` already exists at write time (defense-in-depth\n against double-charging beyond the idempotency-key check). Idempotency\n key used: `PremiumPurchase:<transactionID-or-derived>` (via\n `ResourceService.ResolveRelatedEntityID`).\n8. On success, `Resources` in the response is the actual `ResourceOperation`\n result of the debit (what was consumed, post-discount) — read updated\n balances from the cache, not by re-deriving the discount yourself.\n\nExact rejection strings (verbatim): `\"Invalid PremiumID\"`,\n`\"TransactionID is required\"`, `\"Premium definition not found\"`,\n`\"PriceOption not found\"` (line 261),\n`\"This purchase option has no RequiredResources. Real-money flow is not\nsupported by this endpoint.\"` (line 268), `\"User not found\"` (line 281),\n`\"Purchase failed: {result.Error}\"` (line 375, where `{result.Error}` is\nwhatever `ResourceService` reports — e.g. insufficient funds).\n\n---\n\n## Real-money IAP purchase — current backend status\n\nThe SDK's `purchaseRealMoney(...)` method sends `PremiumAction.PurchaseRealMoney`\nto `v2/{titleID}/Client/Premium/PurchaseRealMoney/{userID}`\n(`packages/core/src/api/PremiumApi.ts:78-86`, action enum in\n`PremiumModels.ts:130`). **As of this read, the v2 `Premium.cs` HTTP handler's\nswitch statement does not implement this action** — its `switch (act)` only\nhas cases for `GetDefinitions`, `GetUserState`, `ActivateTrial`, and\n`PurchaseWithResources`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:52-69`); anything else\n(including `PurchaseRealMoney`) falls through to\n`default: return new BadRequestObjectResult(OperationResult<object>.Fail(\"Action not implemented\"))`\n(line 67).\n\nPractical implications for a consumer right now:\n\n- Calling `client.premium.purchaseRealMoney(...)` will resolve with\n `{ ok: false, reason: \"server\", error: \"Action not implemented\" }` against\n the current backend — it is **not** wired to any App Store/Google Play\n receipt validator in v2.\n- Real-money IAP receipt validation does exist elsewhere in the backend, but\n only in the **legacy v1** surface (`IDosGamesSDK/API/Client/v1/ValidateIAP.cs`,\n `ValidateIAPSubscription.cs`) — that is a different endpoint family, not\n reachable through `client.premium`, and out of scope for this module.\n- Do not build a shipping IAP-subscription flow against `purchaseRealMoney`\n until the backend gains a real handler for this action. If a title needs\n real-money subscriptions today, that requires a backend change outside the\n TS SDK's control — flag it rather than working around it client-side.\n\nThe method, request fields, and response shape are still documented below\nfor completeness (and because the shape is stable/forward-compatible once the\nbackend does implement it), but treat this whole section as **\"designed, not\nyet backed\"** rather than a working call.\n\nRequest fields sent by `purchaseRealMoney(premiumID, transactionID, store,\nproductID, receiptData, purchaseToken?, packageName?, appStoreEnvironment?)`:\n\n```ts\ninterface PremiumRequest {\n PremiumID: string;\n TransactionID: string;\n Store: \"Apple\" | \"Google\"; // StoreType\n ProductID: string; // AppleProductID or GoogleProductID from the definition\n ReceiptData: string; // base64 receipt (Apple) or receipt payload (Google)\n PurchaseToken?: string; // Google Play Billing purchase token\n PackageName?: string; // optional extra context\n AppStoreEnvironment?: string; // optional: e.g. distinguishing sandbox vs production\n}\n```\n\n`client`-side validation in `PremiumService.purchaseRealMoney` requires\n`premiumID`/`transactionID` (`\"PremiumID and TransactionID are required.\"`)\nand `productID`/`receiptData`\n(`\"ProductID and ReceiptData are required.\"`) before it will even attempt\nthe call (`packages/core/src/services/PremiumService.ts:107-111`) — those are\n`reason: \"client\"` failures, not server rejections.\n\n---\n\n## How other modules read a player's tier\n\nPremium's own state (`MaxActiveTier`, active `Subscriptions`) is a\ncross-module dependency. Other modules declare gates/bonuses that reference\nit; **this skill documents only the shape Premium exposes**, not how those\nother modules apply it (that's each module's own skill):\n\n- `SegmentGate.MinPremiumTier` / `SegmentGate.RequiredPremiumIDs`\n (`packages/core/src/models/_shared/SegmentModels.ts:27-28`) — audience\n gating used across Store/Quest/DealOffer/etc.\n- `ResourceConsume.PremiumDiscounts` / `ResourceConsume.PremiumTiers`\n and `ResourceGrant.PremiumTiers`\n (`packages/core/src/models/_shared/ResourceModels.ts:37-54`), each entry a\n `PremiumTierBundle { MinPremiumTier?, RequiredPremiumID?, Resources? }` —\n cost discounts / bonus grants scaled by tier, resolved entirely\n server-side inside `ResourceService`.\n- Reward accrual multipliers, e.g. `PremiumTierMultiplier\n{ MinPremiumTier?, RequiredPremiumID?, Multiplier? }`\n (`packages/core/src/models/reward/RewardModels.ts:192-197`) and\n `ClaimLimitOverride` tier overrides (same file, line 283+).\n- Ad-reduction perks, e.g. `PremiumAdReduction { MinPremiumTier?,\nRequiredPremiumID?, ... }` (`packages/core/src/models/advertising/AdvertisingModels.ts:110-115`).\n\nAll of these follow the same two-field pattern documented in\n[Tier resolution](#tier-resolution-maxactivetier): `RequiredPremiumID` (exact\npass, tier ignored) takes precedence when present, otherwise\n`MinPremiumTier` is compared against `MaxActiveTier`. Client-side, use\n`MaxActiveTier` only to preview/gray-out UI — the actual discount/bonus is\ncomputed and applied server-side inside that other call's own response.\n"
8
+ "content": "# Premium data model — reference\n\nFull shape of the config (Definitions) and player state, the tier-resolution\nand trial rules the backend enforces, and the purchase/receipt flow. All of\nthese are **strictly typed in the SDK** — `PremiumDefinitions` and its nested\nblocks (`PremiumDefinition`, `PriceOption`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<PremiumDefinitions>(\"Premium\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: PremiumDefinitions](#config-premiumdefinitions) — what `getDefinitions()` returns\n- [PremiumDefinition](#premiumdefinition)\n- [PriceOption](#priceoption)\n- [Tier resolution (MaxActiveTier)](#tier-resolution-maxactivetier)\n- [Trial rules](#trial-rules)\n- [Purchase with virtual currency / items](#purchase-with-virtual-currency--items)\n- [Real-money IAP purchase — current backend status](#real-money-iap-purchase--current-backend-status)\n- [How other modules read a player's tier](#how-other-modules-read-a-players-tier)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Premium: UserPremiumState }` and cached at\n`client.data.user.state?.Premium` (full replace on every write — see\n`applyPremium` in `packages/core/src/cache/UserData.ts:672`).\n\n```ts\ninterface UserPremiumState {\n Subscriptions?: Record<string, PremiumSubscription>; // key = PremiumID\n ActivatedTrialIDs?: string[]; // PremiumIDs already trialed — permanent, one-shot\n MaxActiveTier?: number; // highest Tier among currently-active subscriptions\n}\n\ninterface PremiumSubscription {\n PremiumID?: string;\n PurchaseDate?: string; // ISO; set on first purchase, or on renewal after a full lapse\n ExpirationDate?: string; // ISO (UTC); subscription is \"active\" iff this is strictly in the future\n TransactionID?: string; // last transaction that touched this subscription (idempotency key)\n IsAutoRenewEnabled?: boolean; // always false for trial/virtual purchases — see below\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/UserPremiumState.cs:13-40`.\n\nA subscription entry existing in `Subscriptions` does **not** mean it's\nactive — always compare `ExpirationDate` to \"now\" (or just trust\n`MaxActiveTier`, which the backend already recalculates for you on every\nread/write). Expired entries are never deleted; they're left in place so\n`ActivatedTrialIDs`-style history and renewal-on-top-of-lapsed logic keep\nworking. Don't build \"is subscribed\" UI off `Subscriptions[id]` existing —\ncheck its `ExpirationDate`, or better, read `MaxActiveTier`.\n\n---\n\n## Config: PremiumDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<PremiumDefinitions>(\"Premium\")`.\n\n```ts\ninterface PremiumDefinitions {\n Definitions?: Record<string, PremiumDefinition>; // key = PremiumID\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:20-26`.\n\n---\n\n## PremiumDefinition\n\nSelf-contained template for one subscription / premium pass / VIP tier.\n\n```ts\ninterface PremiumDefinition {\n PremiumID?: string; // stable id, e.g. \"silver_vip\" — never renamed after publish\n DisplayName?: string;\n Tier?: number; // 1, 2, 3... higher = more premium; compared against MinPremiumTier gates\n DurationDays?: number; // subscription length; 0 = permanent, 30 = monthly, 365 = yearly\n TrialDurationDays?: number; // 0 = no trial available for this tier\n PriceOptions?: Record<string, PriceOption>; // key = OptionID, e.g. \"Default\"\n AppleProductID?: string; // empty = not sold via App Store\n GoogleProductID?: string; // empty = not sold via Google Play\n Benefits?: Record<string, string>; // free-form slug -> stringified numeric param, for display only\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:35-108`.\n\n- **`Tier`** is the number every other module's gate compares against\n (`SegmentGate.MinPremiumTier`, `ResourceConsume.PremiumTiers` /\n `ResourceGrant.PremiumTiers` entries' `MinPremiumTier`, and any\n `RequiredPremiumID` variants of the same gate — see\n [How other modules read a player's tier](#how-other-modules-read-a-players-tier)).\n- **`DurationDays: 0`** means \"permanent\" — the backend actually implements\n this as expiring **100 years** from purchase (`ComputePurchase`,\n `IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:211`:\n `now.AddYears(100)`), not a literal null-expiration sentinel. Treat any\n `ExpirationDate` more than a few decades out as \"effectively permanent\" in\n UI, but don't special-case `0`/`null` yourself — always compare the actual\n `ExpirationDate`.\n- **`Benefits`** is display-only free-form data (e.g. `\"ExpMult\": \"1.2\"`,\n `\"NoAds\": \"1.0\"`). The SDK does not interpret these keys — a title defines\n its own vocabulary and its own game code reads them for copy/UI. They are\n **not** the mechanism that actually grants discounts/multipliers/gates —\n those are wired up server-side through `ResourceConsume.PremiumDiscounts` /\n `PremiumTiers`, `ResourceGrant.PremiumTiers`, and `SegmentGate.MinPremiumTier`\n independently of `Benefits`.\n\n---\n\n## PriceOption\n\nOne payment option within a `PremiumDefinition.PriceOptions` map — the\nplatform-wide price shape, identical in every module (see the `checkout-system`\nskill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // key within PriceOptions, e.g. \"Default\", \"bundle_a\"\n Name?: string; // optional display name, e.g. \"For Gold\"\n Cost?: ResourceConsume; // debit-only cost; see below\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A store-paid subscription does NOT go through this endpoint.** Renewals and\nrevocations arrive as server notifications from the store with no client request\nto attach them to, so a `Purchase` entry in a premium price is rejected with\n`\"Store-paid subscriptions go through the Purchase module (ValidatePurchase), not\nthrough PurchaseWithResources.\"` — use `client.purchase` for those.\n\nSource: `IDosGamesSDK/API/Client/v2/Premium/Models/PremiumDefinition.cs:117-140`.\n\n`Cost` is a standard `ResourceConsume`\n(`packages/core/src/models/_shared/ResourceModels.ts`) — cost lives in\n`Cost.Standard.Entries` (items/currencies) and/or\n`Cost.Standard.EventTokens`. **`purchaseItemOrCurrency` requires\nat least one of those two to be non-empty** — the backend rejects the call\noutright with `\"This purchase option has no resource cost. Real-money\nflow is not supported by this endpoint.\"` if both are empty (this is how the\nserver tells apart a virtual-cost option from a real-money-only one; see\n[Real-money IAP purchase](#real-money-iap-purchase--current-backend-status)).\n`Cost` may also declare `PremiumDiscounts` — if present, the\nbackend auto-applies the player's own best tier discount when charging, so\nthe amount actually debited can be lower than the raw `Amount` shown in the\noption (same mechanism documented in character-system's stat-cost formulas).\n\nSource of the rejection string: `IDosGamesSDK/API/Client/v2/Premium/Premium.cs:268`.\n\n---\n\n## Tier resolution (MaxActiveTier)\n\n`MaxActiveTier` is **not** stored independently — it's recomputed by\n`PremiumHelpers.RecalculateMaxTier` every time subscriptions change or are\nread (`IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:122-144`):\n\n1. Walk every entry in `Subscriptions`.\n2. Skip any whose `ExpirationDate <= now` (UTC) — expired subscriptions are\n silently ignored, never physically removed.\n3. Skip any `PremiumID` no longer present in the title's `Definitions` (a\n tier that was deleted/renamed from config after the player subscribed).\n4. `MaxActiveTier` = the highest `Tier` among what's left; `0` if nothing\n qualifies.\n\nThis runs on `GetUserState`, `ActivateTrial`, and\n`PurchaseWithResources` — so `MaxActiveTier` is always self-healing: even if\nsubscriptions expire between calls, the very next `getUserState()` (or any\npurchase/trial call) corrects it and persists the correction\n(`NormalizePremiumState`, `IDosGamesSDK/API/Client/v2/Premium/Premium.cs:391-409`).\n**Tiers don't stack** — holding two active subscriptions doesn't add their\ntiers together, it just takes the max.\n\nA separate helper, `PremiumHelpers.HasRequiredPremium`\n(`IDosGamesSDK/API/Client/v2/Premium/Services/PremiumHelpers.cs:44-55`), is\nwhat other modules' gate checks actually call server-side:\n\n- If a gate specifies a `RequiredPremiumID`, it checks only whether that\n exact `PremiumID` has an active subscription — **the tier number is\n ignored** in this branch (owning a specific pass matters, not its rank).\n- Otherwise, if the gate specifies `MinPremiumTier > 0`, it checks\n `MaxActiveTier >= MinPremiumTier`.\n- If neither is specified, the gate passes for everyone.\n\nThis is why `SegmentGate` and the resource-bundle gate types below expose\n**both** `MinPremiumTier` and `RequiredPremiumID`/`RequiredPremiumIDs` —\ntitles choose per-gate whether \"any tier ≥ N\" or \"must own this exact pass\"\nis the right check.\n\n---\n\n## Trial rules\n\n`activateTrial(premiumID, transactionID)` → backend `ActivateTrial`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:140-238`). Checks, in order:\n\n1. `PremiumID` must be a safe Mongo key (no `.` or `$`) — else\n `\"Invalid PremiumID\"`.\n2. `TransactionID` is required — else `\"TransactionID is required\"`.\n3. The tier's definition must exist — else `\"Premium definition not found\"`.\n4. **`TrialDurationDays` must be `> 0`** — else\n `\"Trial is not available for this premium.\"` Not every tier offers a\n trial; check `TrialDurationDays` before showing a trial CTA.\n5. **Idempotent replay**: if the player already has a `Subscriptions[premiumID]`\n entry whose `TransactionID` matches the one just sent, the call returns\n the existing subscription unchanged (no new trial, no error) — this is\n what makes retrying a dropped request safe.\n6. **One trial per `PremiumID` per account, forever**: if `premiumID` is\n already in `ActivatedTrialIDs`, the call fails with\n `\"Trial already used.\"` This list is never cleared — cancelling a trial,\n letting it expire, or unsubscribing does not remove the id, so a player\n can never get a second free trial of the same tier from this endpoint.\n7. If the player has a _currently active_ (non-expired) subscription to that\n same `PremiumID` already, the call fails with\n `\"Subscription already active.\"` — you can't \"trial\" on top of an\n existing live subscription.\n8. On success: a new `PremiumSubscription` is created with\n `ExpirationDate = now + TrialDurationDays`, `IsAutoRenewEnabled: false`,\n `premiumID` is appended to `ActivatedTrialIDs`, and `MaxActiveTier` is\n recalculated. **No resources are consumed or granted** —\n `PremiumPurchaseResponse.Resources` comes back as an empty\n `ResourceOperation` (`Resources: new()`), never `null`, for this call.\n\nExact rejection strings (verbatim, from `Premium.cs`):\n`\"Invalid PremiumID\"` (line 149), `\"TransactionID is required\"` (line 150),\n`\"Premium definition not found\"` (line 154),\n`\"Trial is not available for this premium.\"` (line 156),\n`\"Trial already used.\"` (line 184),\n`\"Subscription already active.\"` (line 189),\n`\"User not found\"` (line 164), `\"Database update failed\"` (line 224).\n\n---\n\n## Purchase with virtual currency / items\n\n`purchaseItemOrCurrency(premiumID, transactionID, selectedOptionID, count)` →\nbackend `PurchaseWithResources`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:240-389`, pure calc in\n`PremiumHelpers.ComputePurchase`, lines 180-255). Checks and behavior, in\norder:\n\n1. `PremiumID` safe-key check, `TransactionID` required — same errors as\n trial.\n2. Definition must exist (`\"Premium definition not found\"`) and\n `selectedOptionID` (default `\"Default\"`) must resolve to a configured\n `PriceOptions` entry (`\"PriceOption not found\"`).\n3. The option's `Cost` must carry at least one item/currency\n entry or event-token entry — otherwise\n `\"This purchase option has no resource cost. Real-money flow is not\nsupported by this endpoint.\"` (this endpoint is virtual-cost only; see\n next section for real money).\n4. **Idempotent replay** (`ComputePurchase`): if `Subscriptions[premiumID]`\n already has this exact `TransactionID`, the call returns the current\n state with **no charge** — safe retry.\n5. **Renewal stacking, not tier stacking**: if the player already has an\n active (non-expired) subscription to the _same_ `PremiumID`, the new\n duration is added **on top of** the existing `ExpirationDate` rather than\n from `now` (`baseTime = existingSub.ExpirationDate` when it's still in the\n future). Buying tier X while X is already active extends it; it does not\n reset the clock or double-grant. `PurchaseDate` is only updated when\n there was no prior subscription or the prior one had fully expired.\n6. `count` (default 1, clamped to minimum 1) multiplies `DurationDays` when\n computing the new expiration (`baseTime.AddDays(DurationDays * count)`) —\n there's no separate \"quantity\" concept beyond stretching the duration.\n `DurationDays <= 0` still resolves to the fixed `+100 years`, ignoring\n `count`.\n7. **Charge and write are atomic together**: the resource debit\n (`Cost`, with the player's own `PremiumDiscounts` applied\n automatically if configured) and the subscription write happen in the\n same `ResourceService.ApplyResourceOperationAtomicAsync` call, guarded\n additionally by a Mongo filter that rejects the write if a subscription\n with this `TransactionID` already exists at write time (defense-in-depth\n against double-charging beyond the idempotency-key check). Idempotency\n key used: `PremiumPurchase:<transactionID-or-derived>` (via\n `ResourceService.ResolveRelatedEntityID`).\n8. On success, `Resources` in the response is the actual `ResourceOperation`\n result of the debit (what was consumed, post-discount) — read updated\n balances from the cache, not by re-deriving the discount yourself.\n\nExact rejection strings (verbatim): `\"Invalid PremiumID\"`,\n`\"TransactionID is required\"`, `\"Premium definition not found\"`,\n`\"PriceOption not found\"` (line 261),\n`\"This purchase option has no resource cost. Real-money flow is not\nsupported by this endpoint.\"` (line 268), `\"User not found\"` (line 281),\n`\"Purchase failed: {result.Error}\"` (line 375, where `{result.Error}` is\nwhatever `ResourceService` reports — e.g. insufficient funds).\n\n---\n\n## Real-money IAP purchase — current backend status\n\nThe SDK's `purchaseRealMoney(...)` method sends `PremiumAction.PurchaseRealMoney`\nto `v2/{titleID}/Client/Premium/PurchaseRealMoney/{userID}`\n(`packages/core/src/api/PremiumApi.ts:78-86`, action enum in\n`PremiumModels.ts:130`). **As of this read, the v2 `Premium.cs` HTTP handler's\nswitch statement does not implement this action** — its `switch (act)` only\nhas cases for `GetDefinitions`, `GetUserState`, `ActivateTrial`, and\n`PurchaseWithResources`\n(`IDosGamesSDK/API/Client/v2/Premium/Premium.cs:52-69`); anything else\n(including `PurchaseRealMoney`) falls through to\n`default: return new BadRequestObjectResult(OperationResult<object>.Fail(\"Action not implemented\"))`\n(line 67).\n\nPractical implications for a consumer right now:\n\n- Calling `client.premium.purchaseRealMoney(...)` will resolve with\n `{ ok: false, reason: \"server\", error: \"Action not implemented\" }` against\n the current backend — it is **not** wired to any App Store/Google Play\n receipt validator in v2.\n- Real-money IAP receipt validation does exist elsewhere in the backend, but\n only in the **legacy v1** surface (`IDosGamesSDK/API/Client/v1/ValidateIAP.cs`,\n `ValidateIAPSubscription.cs`) — that is a different endpoint family, not\n reachable through `client.premium`, and out of scope for this module.\n- Do not build a shipping IAP-subscription flow against `purchaseRealMoney`\n until the backend gains a real handler for this action. If a title needs\n real-money subscriptions today, that requires a backend change outside the\n TS SDK's control — flag it rather than working around it client-side.\n\nThe method, request fields, and response shape are still documented below\nfor completeness (and because the shape is stable/forward-compatible once the\nbackend does implement it), but treat this whole section as **\"designed, not\nyet backed\"** rather than a working call.\n\nRequest fields sent by `purchaseRealMoney(premiumID, transactionID, store,\nproductID, receiptData, purchaseToken?, packageName?, appStoreEnvironment?)`:\n\n```ts\ninterface PremiumRequest {\n PremiumID: string;\n TransactionID: string;\n Store: \"Apple\" | \"Google\"; // StoreType\n ProductID: string; // AppleProductID or GoogleProductID from the definition\n ReceiptData: string; // base64 receipt (Apple) or receipt payload (Google)\n PurchaseToken?: string; // Google Play Billing purchase token\n PackageName?: string; // optional extra context\n AppStoreEnvironment?: string; // optional: e.g. distinguishing sandbox vs production\n}\n```\n\n`client`-side validation in `PremiumService.purchaseRealMoney` requires\n`premiumID`/`transactionID` (`\"PremiumID and TransactionID are required.\"`)\nand `productID`/`receiptData`\n(`\"ProductID and ReceiptData are required.\"`) before it will even attempt\nthe call (`packages/core/src/services/PremiumService.ts:107-111`) — those are\n`reason: \"client\"` failures, not server rejections.\n\n---\n\n## How other modules read a player's tier\n\nPremium's own state (`MaxActiveTier`, active `Subscriptions`) is a\ncross-module dependency. Other modules declare gates/bonuses that reference\nit; **this skill documents only the shape Premium exposes**, not how those\nother modules apply it (that's each module's own skill):\n\n- `SegmentGate.MinPremiumTier` / `SegmentGate.RequiredPremiumIDs`\n (`packages/core/src/models/_shared/SegmentModels.ts:27-28`) — audience\n gating used across Store/Quest/DealOffer/etc.\n- `ResourceConsume.PremiumDiscounts` / `ResourceConsume.PremiumTiers`\n and `ResourceGrant.PremiumTiers`\n (`packages/core/src/models/_shared/ResourceModels.ts:37-54`), each entry a\n `PremiumTierBundle { MinPremiumTier?, RequiredPremiumID?, Resources? }` —\n cost discounts / bonus grants scaled by tier, resolved entirely\n server-side inside `ResourceService`.\n- Reward accrual multipliers, e.g. `PremiumTierMultiplier\n{ MinPremiumTier?, RequiredPremiumID?, Multiplier? }`\n (`packages/core/src/models/reward/RewardModels.ts:192-197`) and\n `ClaimLimitOverride` tier overrides (same file, line 283+).\n- Ad-reduction perks, e.g. `PremiumAdReduction { MinPremiumTier?,\nRequiredPremiumID?, ... }` (`packages/core/src/models/advertising/AdvertisingModels.ts:110-115`).\n\nAll of these follow the same two-field pattern documented in\n[Tier resolution](#tier-resolution-maxactivetier): `RequiredPremiumID` (exact\npass, tier ignored) takes precedence when present, otherwise\n`MinPremiumTier` is compared against `MaxActiveTier`. Client-side, use\n`MaxActiveTier` only to preview/gray-out UI — the actual discount/bonus is\ncomputed and applied server-side inside that other call's own response.\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,7 +1,7 @@
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 `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",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",