@idosgames/mcp 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +19 -3
- package/registry/index.json +119 -23
- package/registry/modules/board-game.json +31 -10
- package/registry/modules/game-hud.json +99 -0
- package/registry/modules/idle-rpg.json +35 -10
- package/registry/modules/voxelcraft.json +7 -2
- package/registry/skills/acquisition-attribution.json +1 -1
- package/registry/skills/blockchain-system.json +2 -2
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/chat-system.json +6 -0
- package/registry/skills/community-marketing-system.json +6 -0
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/currency-system.json +2 -2
- package/registry/skills/deal-offer-system.json +2 -2
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +6 -0
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/push-notifications.json +6 -0
- package/registry/skills/store-system.json +3 -3
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deal-offer-system",
|
|
3
3
|
"description": "Build a personalized / targeted deal-offer system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.dealOffer (DealOfferService): load slot and offer definitions, load the player's deal-offer state, fetch the currently active deals per slot, dismiss a deal, execute a node in an offer's graph (purchase / free claim / rewarded-video / info step), record an impression (show event), and claim a milestone reward (single or batch). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants limited-time offer popups, IAP funnels, \"special offer\" slots, node/graph-based offer chains, rewarded-video offer steps, offer milestone/progress bars, or otherwise touches client.dealOffer, DealOfferService, DealOfferDefinitions, DealNodeDefinition, UserDealOffersState, ActiveDealSlotInfo, or ExecuteNodeResponse — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: deal-offer-system\ndescription: >-\n Build a personalized / targeted deal-offer system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.dealOffer (DealOfferService):\n load slot and offer definitions, load the player's deal-offer state, fetch\n the currently active deals per slot, dismiss a deal, execute a node in an\n offer's graph (purchase / free claim / rewarded-video / info step), record\n an impression (show event), and claim a milestone reward (single or batch).\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants limited-time offer popups, IAP\n funnels, \"special offer\" slots, node/graph-based offer chains, rewarded-video\n offer steps, offer milestone/progress bars, or otherwise touches\n client.dealOffer, DealOfferService, DealOfferDefinitions, DealNodeDefinition,\n UserDealOffersState, ActiveDealSlotInfo, or ExecuteNodeResponse — even if\n they don't name the module explicitly.\n---\n\n# Deal offer system (iDosGames TS SDK)\n\nThe Deal Offer module runs targeted, time-boxed offer popups (\"special offer\",\n\"starter pack\", \"welcome bundle chain\") shown in fixed **slots** on screen.\nEverything is **server-authoritative**: the client asks the backend to\nexecute a step, dismiss, or claim, the backend validates and charges/grants,\nand the SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate deal state yourself — you call a method, check the result, and\nrender from the cache.\n\nThis skill is for **using** the production `DealOfferService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (cost, gate, lock, cooldown) — surface the error, don't try to reproduce\nthe check client-side.\n\n## The mental model: slots, offers, and the node graph\n\nThree nested concepts. Keep them straight — every method operates on one of\nthem.\n\n1. **Slot** (`DealSlotDefinition`) — a fixed UI position (e.g. `\"Slot1\"`) that\n cycles through a **queue** of offers over time, on a `Schedule`, gated by a\n `SegmentGate`. At any moment a slot has at most one **active offer\n activation**.\n2. **Offer** (`DealOfferDefinition`) — one specific deal (e.g. a 3-step\n starter pack). An offer is not a flat \"buy this for that\" — it's a **graph\n of nodes** the player works through during one activation.\n3. **Node** (`DealNodeDefinition`) — one atomic player action inside the\n offer's graph: a `Purchase`, a `FreeClaim`, a `RewardedVideo` view, or an\n `Info` step. Executing a node is the unit of progress.\n\n### What the node graph actually is\n\nAn offer's `Nodes` array is a small directed graph, not a list:\n\n- `RootNodeIDs` names the node(s) available the instant a fresh activation\n starts — no server round trip needed to \"unlock\" them.\n- Each node's `NextNodeIDs` names the node(s) that become reachable once\n **this** node is completed — that's the graph's real edge list. A node can\n have zero (terminal), one (linear chain), or several (branching)\n next-nodes.\n- Each node also carries `UnlockRules` (`RequiredCompletedNodeIDs` /\n `RequiredAnyCompletedNodeIDs` / `RequiredTracks`), but **`NextNodeIDs` is\n the thing that actually unlocks a node server-side** — completing a node\n writes `Available` state to every ID in its `NextNodeIDs` unconditionally.\n A non-root node with no runtime state yet is always rejected (\"Node is\n locked\"), even if its own `UnlockRules` look satisfiable on paper — the\n server never bootstraps a node's state from `UnlockRules` alone. The one\n place `RequiredTracks` really does unlock nodes on its own is tracks (see\n below). Treat `UnlockRules` mainly as UI-hint metadata (what a locked node\n is \"waiting on\") rather than a client-computable gate — see\n [references/data-model.md](references/data-model.md) for the exact\n algorithm.\n- `GraphMode` (`Single | Chain | BranchingChain | Choice | MeteredChain`)\n describes the _shape_ the title author intended — it's descriptive metadata\n on the offer, not something the client interprets differently. The actual\n traversal is always just `NextNodeIDs` + `UnlockRules` + (for `Choice`)\n `ChoiceGroupID`.\n- **Executing a node** (`executeNode(slotID, nodeID, externalRefID?, options?)`)\n is \"the player performed this node's action right now\": pay its\n `Action.Purchase` cost (if `UseExternalRewards` is false and `PriceOptions` is\n set — `options.selectedOptionID` picks which way to pay, `options.payment`\n carries the store receipt when that option is paid in a store, which is the main\n monetization path of deal offers), or register a\n `RewardedVideo` view, or acknowledge a `FreeClaim`/`Info` node — then the\n backend applies `Grants` (skipped for `Purchase` nodes with\n `UseExternalRewards: true`, and for `RewardedVideo` mid-sequence views\n unless `GrantRewardsPerView` is true), applies `TrackChanges`, adds\n `MilestonePoints` to the offer's milestone bar, and marks the node\n `Completed` once its execution count reaches the required amount (1 by\n default; `Limits.PerActivationCap` for ordinary nodes,\n `Action.RewardedVideo.ViewsRequiredToComplete` for ad nodes). The response\n tells you `NodeCompleted` (this call finished the node) and\n `OfferExhausted` (this completion ended the whole activation, e.g. via\n `ExhaustOfferOnComplete` or all terminal nodes now being complete).\n- **Tracks** (`DealTrackDefinition`) are small offer-local counters (e.g.\n \"shells collected this activation\") that nodes write via `TrackChanges` and\n that can independently unlock nodes whose `UnlockRules.RequiredTracks`\n threshold is crossed — a lightweight in-offer state machine layered on top\n of node completion, reset to `StartValue` every fresh activation.\n- **Milestones** are a separate reward ladder over `MilestonePoints` earned\n from nodes in this same activation — see `claimMilestone` below.\n\nIn short: a **slot** shows one **offer** at a time; an **offer** is a graph of\n**nodes**; executing a node pays/claims that node and can unlock further\nnodes via `NextNodeIDs` (or via a track threshold); enough node completions\ncan exhaust the offer and/or clear milestone thresholds. Always read the\nreturned `DealNodeRuntimeStatus` per node (`Locked | Available | InProgress |\nCompleted | Hidden`) as ground truth rather than computing it yourself.\n\nFor the full field-by-field shape of slots, offers, nodes, tracks,\nmilestones, runtime state, the exact unlock algorithm, the milestone\naddressing/reward math, and slot-resolution/schedule-mode rules, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config (progress bars,\nlocked/available badges, choice-group rendering).\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 deals = client.dealOffer; // the DealOfferService\n```\n\nEvery deal-offer 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\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. `\"Node 'X' is\nlocked\"`, `\"Node 'X' is already completed\"`, `\"Deal in slot 'Y' has\nexpired\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |\n| `getDefinition()` | Load the title's slot + offer catalog (config). | `DealOffersDefinitionResponse` (`DealOfferDefinitions`) |\n| `getUserState()` | Load this player's raw deal-offer state (all slots + history). | `UserDealOffersStateResponse` (`DealOffers`) |\n| `getActiveDeals()` | Load the resolved, ready-to-render active offer per slot. | `GetActiveDealsResponse` (`Slots: ActiveDealSlotInfo[]`) |\n| `dismissDeal(slotID)` | Dismiss the active offer in a slot before finishing it. | `DismissDealResponse` |\n| `executeNode(slotID, nodeID, externalRefID?)` | Perform one node's action (purchase / claim / ad view / ack info). | `ExecuteNodeResponse` (`NodeCompleted`, `OfferExhausted`, `Idempotent`) |\n| `recordShow(slotID)` | Record an impression (the offer popup was shown to the player). | `RecordShowResponse` |\n| `claimMilestone(slotID, milestoneID)` | Claim one reached-and-unclaimed milestone reward. | `ClaimDealMilestoneResponse` |\n| `claimMilestonesBatch(slotID, milestoneIDs)` | Claim many milestones for one slot's active offer in one call. | `ClaimDealMilestonesBatchResponse` (`ClaimedIDs`, `Rejected`) |\n\n`getActiveDeals()` is the one to render a deal popup/carousel from directly —\neach `ActiveDealSlotInfo` bundles the slot's offer definition (`OfferDef`),\nthe player's runtime progress on it (`ActivationState`), whether this is a\nfreshly-started activation (`IsNewActivation`), a computed expiry\n(`ComputedExpiresAtUtc`), an aggregated cost preview (`Cost`), and milestone\nprogress (`Milestone`) — you don't have to manually join `getDefinition()` +\n`getUserState()` yourself, though both remain available for lower-level reads\n(e.g. offer history, or definitions for slots with no active offer). Slots\nthat fail their audience `Gate`, have no live/next offer, or are paused\nbetween cycles are simply omitted from `Slots` — there's no \"locked slot\"\nplaceholder.\n\nOn success, each method **emits an event**; only `dismissDeal`, `executeNode`,\n`recordShow`, `claimMilestone`, and `claimMilestonesBatch` also carry a\n`Resources: ResourceOperation` that's mirrored into the inventory/currency\ncache (grants and/or consumes already applied — read updated balances from\n`client.data.user.state?.<Currency/Item>` as usual). `dismissDeal` and\n`recordShow` always carry an empty `Resources` (they never move\ncurrency/items — the field exists for response-shape consistency).\n`executeNode` skips re-applying resources when `data.Idempotent` is true (a\nretried/duplicate call replaying a result already applied server-side without\nopening a new transaction). Note that unlike some other modules, the service\ndoes **not** yet optimistically patch the per-slot/per-node runtime state in\nthe cache on these five calls — refresh with `getUserState()` /\n`getActiveDeals()` (or rely on the event payload) to see updated node\nstatuses.\n\n## Reading state and reacting to changes\n\n```ts\n// Raw per-slot runtime state (present after getUserState() or getActiveDeals()):\nconst dealState = client.data.user.state?.DealOffer;\nconst slot = dealState?.Slots?.[\"Slot1\"];\nslot?.ActiveOfferID;\nslot?.ActiveOffer?.Nodes?.[\"node_1\"]?.Status; // \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\"\nslot?.ActiveOffer?.Tracks?.[\"shells\"]?.CurrentValue;\n\n// Definitions (cached after getDefinition()):\nimport type { DealOfferDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<DealOfferDefinitions>(\"DealOffer\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `dealOffer:definitionLoaded` → `DealOfferDefinitions`\n- `dealOffer:userStateLoaded` → `UserDealOffersState`\n- `dealOffer:activeDealsLoaded` → `GetActiveDealsResponse`\n- `dealOffer:dealDismissed` → `DismissDealResponse`\n- `dealOffer:nodeExecuted` → `ExecuteNodeResponse`\n- `dealOffer:showRecorded` → `RecordShowResponse`\n- `dealOffer:milestoneClaimed` → `ClaimDealMilestoneResponse`\n- `dealOffer:milestonesBatchClaimed` → `ClaimDealMilestonesBatchResponse`\n\nThe coarse `user:dealOfferUpdated` (and `user:anyUpdated`) fire only from\n`getUserState()` (which replaces the whole cached `DealOffer` state via\n`applyDealOffer`) — not from the other six calls, since those don't yet patch\n`client.data.user.state?.DealOffer` themselves. Treat the specific\n`dealOffer:*` event payload above as the source of truth for what just\nhappened, and call `getActiveDeals()` / `getUserState()` afterward if you need\nthe refreshed per-slot cache.\n\n```ts\nconst off = client.on(\"dealOffer:nodeExecuted\", (r) => {\n console.log(\n `node ${r.NodeID} completed=${r.NodeCompleted} exhausted=${r.OfferExhausted}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the active deal in a slot\n\n```ts\nawait client.dealOffer.getDefinition();\nconst res = await client.dealOffer.getActiveDeals();\nif (!res.ok) return showError(res.error);\n\nfor (const slot of res.data.Slots ?? []) {\n // slot.OfferDef.Nodes describes the graph; slot.ActivationState.Nodes has\n // per-node runtime Status/ExecutionCount for THIS activation.\n const rootNodes = (slot.OfferDef?.RootNodeIDs ?? []).map((id) =>\n slot.OfferDef?.Nodes?.find((n) => n.NodeID === id),\n );\n // render rootNodes first; reveal further nodes as their Status flips to\n // \"Available\"/\"Completed\" after each executeNode call.\n}\n```\n\n### Record an impression, then execute a node (golden path)\n\n```ts\nawait client.dealOffer.recordShow(\"Slot1\"); // fire once when the popup opens\n\nconst res = await client.dealOffer.executeNode(\"Slot1\", \"node_purchase_1\");\nif (!res.ok) return showError(res.error); // e.g. \"Node 'node_purchase_1' is locked\", insufficient funds\nif (res.data.NodeCompleted) unlockNextNodesInUI();\nif (res.data.OfferExhausted) closeDealPopup(); // no more nodes to work through\n```\n\n### Rewarded-video node needing multiple views\n\nA `RewardedVideo` node's `Action.RewardedVideo.ViewsRequiredToComplete` can be\ngreater than 1 — call `executeNode` again after each ad view; the node only\nflips to completed (and grants `Grants`, unless `GrantRewardsPerView` is set)\nonce enough views have been recorded.\n\n```ts\nasync function watchAdForNode(slotID: string, nodeID: string) {\n await showRewardedAd(); // your ad SDK\n const res = await client.dealOffer.executeNode(slotID, nodeID);\n if (!res.ok) return showError(res.error);\n if (!res.data.NodeCompleted) {\n // still needs more views — show \"1 of N watched\" from ActivationState.Nodes[nodeID].ExecutionCount\n }\n}\n```\n\nPer-activation view cap is derived from the ad config\n(`max(MaxViewsPerActivation, ViewsRequiredToComplete)`, or unlimited if\n`MaxViewsPerActivation <= 0`) — it's never accidentally lower than what's\nneeded to finish the node. `CooldownSecondsBetweenViews` (if set) rejects an\nearly retry with `\"Node 'X' is on cooldown until <time>\"`.\n\n### Claim milestones (single, then batch)\n\n```ts\nconst one = await client.dealOffer.claimMilestone(\"Slot1\", \"milestone_1\");\nif (!one.ok) return showError(one.error); // e.g. \"Not enough earned...\", \"Milestone already claimed.\"\n\nconst batch = await client.dealOffer.claimMilestonesBatch(\"Slot1\", [\n \"milestone_2\",\n \"milestone_3\",\n \"milestone_2\", // duplicates are deduped client-side before the call\n]);\nif (!batch.ok) return showError(batch.error);\nbatch.data.ClaimedIDs; // milestone IDs that were actually claimed\nbatch.data.Rejected; // Record<milestoneID, reasonString> for ones that weren't\n```\n\nThe milestone bar's progress address is derived from the offer's _current_\nslot position (`\"{offerID}:{slotID}:c{cycleIndex}:q{queueIndex}\"`), so it\nresets to zero automatically whenever the slot advances to a new queue entry\nor cycle — there's no explicit \"reset the bar\" call. A `MilestoneClaimMode`\nof `AfterEventEnd` (or `FeaturedAfterEnd`, for `IsFeatured` milestones only)\nrejects the claim with `\"Milestone can only be claimed after the offer\nends.\"` until the activation is no longer live — see\n[references/data-model.md](references/data-model.md) for the exact\n\"activation ended\" check.\n\n### Dismiss a deal early\n\n```ts\nconst res = await client.dealOffer.dismissDeal(\"Slot1\");\nif (!res.ok) return showError(res.error);\n// slot's activation is marked Dismissed server-side; the slot's queue can\n// advance to the next offer per its Schedule/AllowDismissSkip config.\n```\n\n## Gotchas\n\n- **The node graph's real edge list is `NextNodeIDs`, not `UnlockRules`.**\n Completing a node writes `Available` state to every ID in its `NextNodeIDs`\n unconditionally; a non-root node with no runtime state yet is always\n rejected regardless of whether its own `UnlockRules` look satisfied. The\n one exception is `UnlockRules.RequiredTracks`, which genuinely does unlock\n a node the moment the relevant track crosses its threshold. Don't compute\n node availability client-side — read `ActivationState.Nodes[nodeID].Status`\n (refreshed via `getActiveDeals()` / `getUserState()`, or the latest\n `dealOffer:nodeExecuted` event).\n- **Cache isn't optimistically patched for five of the eight calls.**\n `dismissDeal`, `executeNode`, `recordShow`, `claimMilestone`, and\n `claimMilestonesBatch` mirror resource grants/consumes into the\n currency/item cache, but they do **not** patch\n `client.data.user.state?.DealOffer` themselves (per the \"optimistic slot\n patches deferred\" note in the source) — only `getUserState()` does, via\n `applyDealOffer`, which replaces the whole `DealOffer` state wholesale.\n Re-fetch `getActiveDeals()`/`getUserState()` after a mutating call if your\n UI needs the updated per-node/per-slot status rather than relying on stale\n cache reads.\n- **`executeNode`'s `Idempotent` flag matters.** When `true`, the resources in\n the response were already applied by an earlier call with the same\n `RelatedEntityID` (matched against that node's last stored execution ref) —\n the service intentionally skips both re-applying them and opening a new\n transaction. Pass your own `externalRefID` if you need a stable idempotency\n key across retries (e.g. after a network drop); otherwise the SDK mints a\n fresh `deal_exec_<slot>_<node>_<uuid>` each call — still disable the\n control while a call is in flight to guard against double-submits on the UI\n side.\n- **Milestones are a separate reward ladder from node `Grants`, and go\n through the platform-wide progression-multiplier resolver.** A node's\n `Grants` pay out immediately on that node's completion; `MilestonePoints`\n from completed nodes accumulate toward the offer's `Milestones` thresholds,\n which need their own explicit `claimMilestone`/`claimMilestonesBatch` call\n — reaching a threshold does not auto-grant its reward. The actual payout is\n the milestone's base `Rewards` scaled by the title's\n `Reward.MilestoneRewardMultiplier` progression curve if one is configured\n (same resolver Quest/CommunityChest/Referral use) — don't assume the\n claimed amount equals the milestone's raw `Rewards` field.\n- **Batch milestone claims are partial-aware, not all-or-nothing on\n rejection, and uncapped in size.** `ClaimedIDs` / `Rejected` tell you\n per-milestone outcome inside one call; an entry can be rejected on its own\n merits (not yet reached, already claimed, wrong claim-mode timing)\n independent of the others. There's no batch-size cap like Character's\n 50-item limit — send however many milestone IDs you have for one slot. The\n combined resource grant across all claimed IDs in a batch is applied as one\n atomic operation (one Mongo write, one merged `Resources.Grant` summing\n every claimed milestone's reward) — but which IDs land in `Claimed` vs\n `Rejected` is decided before that atomicity boundary.\n- **Tracks reset per activation.** `Tracks` on `ActivationState` belong to the\n current offer activation in that slot — when the slot cycles to the next\n queued offer (or the same offer re-activates later), track values start\n fresh per the offer's `DealTrackDefinition.StartValue`, they don't carry\n over. Lifetime counting instead lives in `OfferHistory`/`NodeCounts`.\n- **`AllowDismissSkip` changes what dismiss actually does.** With it `false`\n (the default), `dismissDeal` only marks the activation `Dismissed` and the\n slot's existing timer keeps ticking — the same (now-inert) offer stays\n \"current\" until it naturally expires. With it `true` (meant for\n one-time-offer slots), dismissing lets the slot advance to the next queued\n offer after `DismissSkipDelaySec` seconds.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: slot queues/schedule modes, the full node/graph shape and its\nexact unlock/exhaustion algorithm, tracks, node execution limits, the\nmilestone-bar addressing and reward-multiplier math, cost-preview\naggregation, and the idempotency/OCC mechanics behind node execution.\n",
|
|
4
|
+
"content": "---\nname: deal-offer-system\ndescription: >-\n Build a personalized / targeted deal-offer system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.dealOffer (DealOfferService):\n load slot and offer definitions, load the player's deal-offer state, fetch\n the currently active deals per slot, dismiss a deal, execute a node in an\n offer's graph (purchase / free claim / rewarded-video / info step), record\n an impression (show event), and claim a milestone reward (single or batch).\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants limited-time offer popups, IAP\n funnels, \"special offer\" slots, node/graph-based offer chains, rewarded-video\n offer steps, offer milestone/progress bars, or otherwise touches\n client.dealOffer, DealOfferService, DealOfferDefinitions, DealNodeDefinition,\n UserDealOffersState, ActiveDealSlotInfo, or ExecuteNodeResponse — even if\n they don't name the module explicitly.\n---\n\n# Deal offer system (iDosGames TS SDK)\n\nThe Deal Offer module runs targeted, time-boxed offer popups (\"special offer\",\n\"starter pack\", \"welcome bundle chain\") shown in fixed **slots** on screen.\nEverything is **server-authoritative**: the client asks the backend to\nexecute a step, dismiss, or claim, the backend validates and charges/grants,\nand the SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate deal state yourself — you call a method, check the result, and\nrender from the cache.\n\nThis skill is for **using** the production `DealOfferService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (cost, gate, lock, cooldown) — surface the error, don't try to reproduce\nthe check client-side.\n\n## The mental model: slots, offers, and the node graph\n\nThree nested concepts. Keep them straight — every method operates on one of\nthem.\n\n1. **Slot** (`DealSlotDefinition`) — a fixed UI position (e.g. `\"Slot1\"`) that\n cycles through a **queue** of offers over time, on a `Schedule`, gated by a\n `SegmentGate`. At any moment a slot has at most one **active offer\n activation**.\n2. **Offer** (`DealOfferDefinition`) — one specific deal (e.g. a 3-step\n starter pack). An offer is not a flat \"buy this for that\" — it's a **graph\n of nodes** the player works through during one activation.\n3. **Node** (`DealNodeDefinition`) — one atomic player action inside the\n offer's graph: a `Purchase`, a `FreeClaim`, a `RewardedVideo` view, or an\n `Info` step. Executing a node is the unit of progress.\n\n### What the node graph actually is\n\nAn offer's `Nodes` array is a small directed graph, not a list:\n\n- `RootNodeIDs` names the node(s) available the instant a fresh activation\n starts — no server round trip needed to \"unlock\" them.\n- Each node's `NextNodeIDs` names the node(s) that become reachable once\n **this** node is completed — that's the graph's real edge list. A node can\n have zero (terminal), one (linear chain), or several (branching)\n next-nodes.\n- Each node also carries `UnlockRules` (`RequiredCompletedNodeIDs` /\n `RequiredAnyCompletedNodeIDs` / `RequiredTracks`), and they are a **real\n gate**: a non-root node with no runtime state yet is checked against them and\n plays if they are satisfied in this activation. `NextNodeIDs` remains the\n fast path — completing a node writes `Available` state to every ID it lists,\n unconditionally — but it is no longer the only way in. A non-root node that\n declares no rules at all is still refused (\"Node is locked\"): nothing could\n open it except a push. Still read\n `ActivationState.Nodes[nodeID].Status` from the server instead of unlocking\n locally — see [references/data-model.md](references/data-model.md) for the\n exact algorithm.\n- ⚠ **Slots, offers and nodes are decomposed into named blocks** — `Identity`, `Availability`,\n `Behaviour` on a slot; `Identity`, `Graph`, `Milestones` on an offer; `Identity`, `Pricing`,\n `Reward` on a node — and each block has its own independent preset binding. Presets are resolved\n SERVER-SIDE once when the config is materialised, so what you receive is already assembled.\n Practically this only changes where you read a field from: nodes are `offer.Graph.Nodes`, the\n price is `node.Pricing.Options`, the reward is `node.Reward.Grants`, the milestone ladder is\n `offer.Milestones.Milestones`. An absent block means \"not set\", which is a valid state — read\n through the documented defaults rather than assuming.\n- There is no declared \"graph mode\" field. The shape of an offer (single node,\n linear chain, branching, choice, metered) is whatever `RootNodeIDs`,\n `NextNodeIDs`, `ChoiceGroupID` and `UnlockRules` actually describe — derive\n it from the graph if your UI wants to label it, and it can never disagree\n with how the offer really behaves.\n- **Executing a node** (`executeNode(slotID, nodeID, externalRefID?, options?)`)\n is \"the player performed this node's action right now\": pay the node's\n `PriceOptions` cost — `options.selectedOptionID` picks which way to pay,\n `options.payment` carries the store receipt when that option is paid in a\n store, which is the main monetization path of deal offers — then the backend\n applies `Grants` (for `RewardedVideo` mid-sequence views only when\n `GrantRewardsPerView` is true), applies `TrackChanges`, adds\n `MilestonePoints` to the offer's milestone bar, and marks the node\n `Completed` once its execution count reaches the required amount (1 by\n default; `Limits.PerActivationCap` for ordinary nodes,\n `Action.RewardedVideo.ViewsRequiredToComplete` for ad nodes). The response\n tells you `NodeCompleted` (this call finished the node) and\n `OfferExhausted` (this completion ended the whole activation, e.g. via\n `ExhaustOfferOnComplete` or all terminal nodes now being complete).\n- **Tracks** (`DealTrackDefinition`) are small offer-local counters (e.g.\n \"shells collected this activation\") that nodes write via `TrackChanges` and\n that can independently unlock nodes whose `UnlockRules.RequiredTracks`\n threshold is crossed — a lightweight in-offer state machine layered on top\n of node completion, reset to `StartValue` every fresh activation.\n- **Milestones** are a separate reward ladder over `MilestonePoints` earned\n from nodes in this same activation — see `claimMilestone` below.\n\nIn short: a **slot** shows one **offer** at a time; an **offer** is a graph of\n**nodes**; executing a node pays/claims that node and can unlock further\nnodes via `NextNodeIDs` (or via a track threshold); enough node completions\ncan exhaust the offer and/or clear milestone thresholds. Always read the\nreturned `DealNodeRuntimeStatus` per node (`Locked | Available | InProgress |\nCompleted | Hidden`) as ground truth rather than computing it yourself.\n\nFor the full field-by-field shape of slots, offers, nodes, tracks,\nmilestones, runtime state, the exact unlock algorithm, the milestone\naddressing/reward math, and slot-resolution/schedule-mode rules, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config (progress bars,\nlocked/available badges, choice-group rendering).\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 deals = client.dealOffer; // the DealOfferService\n```\n\nEvery deal-offer 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\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. `\"Node 'X' is\nlocked\"`, `\"Node 'X' is already completed\"`, `\"Deal in slot 'Y' has\nexpired\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |\n| `getDefinition()` | Load the title's slot + offer catalog (config). | `DealOffersDefinitionResponse` (`DealOfferDefinitions`) |\n| `getUserState()` | Load this player's raw deal-offer state (all slots + history). | `UserDealOffersStateResponse` (`DealOffers`) |\n| `getActiveDeals()` | Load the resolved, ready-to-render active offer per slot. | `GetActiveDealsResponse` (`Slots: ActiveDealSlotInfo[]`) |\n| `dismissDeal(slotID)` | Dismiss the active offer in a slot before finishing it. | `DismissDealResponse` |\n| `executeNode(slotID, nodeID, externalRefID?)` | Perform one node's action (purchase / claim / ad view / ack info). | `ExecuteNodeResponse` (`NodeCompleted`, `OfferExhausted`, `Idempotent`) |\n| `recordShow(slotID)` | Record an impression (the offer popup was shown to the player). | `RecordShowResponse` |\n| `claimMilestone(slotID, milestoneID)` | Claim one reached-and-unclaimed milestone reward. | `ClaimDealMilestoneResponse` |\n| `claimMilestonesBatch(slotID, milestoneIDs)` | Claim many milestones for one slot's active offer in one call. | `ClaimDealMilestonesBatchResponse` (`ClaimedIDs`, `Rejected`) |\n\n`getActiveDeals()` is the one to render a deal popup/carousel from directly —\neach `ActiveDealSlotInfo` bundles the slot's offer definition (`OfferDef`),\nthe player's runtime progress on it (`ActivationState`), whether this is a\nfreshly-started activation (`IsNewActivation`), a computed expiry\n(`ComputedExpiresAtUtc`), an aggregated cost preview (`Cost`), and milestone\nprogress (`Milestone`) — you don't have to manually join `getDefinition()` +\n`getUserState()` yourself, though both remain available for lower-level reads\n(e.g. offer history, or definitions for slots with no active offer). Slots\nthat fail their audience `Gate`, have no live/next offer, or are paused\nbetween cycles are simply omitted from `Slots` — there's no \"locked slot\"\nplaceholder.\n\nOn success, each method **emits an event**; only `dismissDeal`, `executeNode`,\n`recordShow`, `claimMilestone`, and `claimMilestonesBatch` also carry a\n`Resources: ResourceOperation` that's mirrored into the inventory/currency\ncache (grants and/or consumes already applied — read updated balances from\n`client.data.user.state?.<Currency/Item>` as usual). `dismissDeal` and\n`recordShow` always carry an empty `Resources` (they never move\ncurrency/items — the field exists for response-shape consistency).\n`executeNode` skips re-applying resources when `data.Idempotent` is true (a\nretried/duplicate call replaying a result already applied server-side without\nopening a new transaction). Note that unlike some other modules, the service\ndoes **not** yet optimistically patch the per-slot/per-node runtime state in\nthe cache on these five calls — refresh with `getUserState()` /\n`getActiveDeals()` (or rely on the event payload) to see updated node\nstatuses.\n\n## Reading state and reacting to changes\n\n```ts\n// Raw per-slot runtime state (present after getUserState() or getActiveDeals()):\nconst dealState = client.data.user.state?.DealOffer;\nconst slot = dealState?.Slots?.[\"Slot1\"];\nslot?.ActiveOfferID;\nslot?.ActiveOffer?.Nodes?.[\"node_1\"]?.Status; // \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\"\nslot?.ActiveOffer?.Tracks?.[\"shells\"]?.CurrentValue;\n\n// Definitions (cached after getDefinition()):\nimport type { DealOfferDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<DealOfferDefinitions>(\"DealOffer\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `dealOffer:definitionLoaded` → `DealOfferDefinitions`\n- `dealOffer:userStateLoaded` → `UserDealOffersState`\n- `dealOffer:activeDealsLoaded` → `GetActiveDealsResponse`\n- `dealOffer:dealDismissed` → `DismissDealResponse`\n- `dealOffer:nodeExecuted` → `ExecuteNodeResponse`\n- `dealOffer:showRecorded` → `RecordShowResponse`\n- `dealOffer:milestoneClaimed` → `ClaimDealMilestoneResponse`\n- `dealOffer:milestonesBatchClaimed` → `ClaimDealMilestonesBatchResponse`\n\nThe coarse `user:dealOfferUpdated` (and `user:anyUpdated`) fire only from\n`getUserState()` (which replaces the whole cached `DealOffer` state via\n`applyDealOffer`) — not from the other six calls, since those don't yet patch\n`client.data.user.state?.DealOffer` themselves. Treat the specific\n`dealOffer:*` event payload above as the source of truth for what just\nhappened, and call `getActiveDeals()` / `getUserState()` afterward if you need\nthe refreshed per-slot cache.\n\n```ts\nconst off = client.on(\"dealOffer:nodeExecuted\", (r) => {\n console.log(\n `node ${r.NodeID} completed=${r.NodeCompleted} exhausted=${r.OfferExhausted}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the active deal in a slot\n\n```ts\nawait client.dealOffer.getDefinition();\nconst res = await client.dealOffer.getActiveDeals();\nif (!res.ok) return showError(res.error);\n\nfor (const slot of res.data.Slots ?? []) {\n // slot.OfferDef.Nodes describes the graph; slot.ActivationState.Nodes has\n // per-node runtime Status/ExecutionCount for THIS activation.\n const rootNodes = (slot.OfferDef?.RootNodeIDs ?? []).map((id) =>\n slot.OfferDef?.Nodes?.find((n) => n.NodeID === id),\n );\n // render rootNodes first; reveal further nodes as their Status flips to\n // \"Available\"/\"Completed\" after each executeNode call.\n}\n```\n\n### Record an impression, then execute a node (golden path)\n\n```ts\nawait client.dealOffer.recordShow(\"Slot1\"); // fire once when the popup opens\n\nconst res = await client.dealOffer.executeNode(\"Slot1\", \"node_purchase_1\");\nif (!res.ok) return showError(res.error); // e.g. \"Node 'node_purchase_1' is locked\", insufficient funds\nif (res.data.NodeCompleted) unlockNextNodesInUI();\nif (res.data.OfferExhausted) closeDealPopup(); // no more nodes to work through\n```\n\n### Rewarded-video node needing multiple views\n\nA `RewardedVideo` node's `Action.RewardedVideo.ViewsRequiredToComplete` can be\ngreater than 1 — call `executeNode` again after each ad view; the node only\nflips to completed (and grants `Grants`, unless `GrantRewardsPerView` is set)\nonce enough views have been recorded.\n\n```ts\nasync function watchAdForNode(slotID: string, nodeID: string) {\n await showRewardedAd(); // your ad SDK\n const res = await client.dealOffer.executeNode(slotID, nodeID);\n if (!res.ok) return showError(res.error);\n if (!res.data.NodeCompleted) {\n // still needs more views — show \"1 of N watched\" from ActivationState.Nodes[nodeID].ExecutionCount\n }\n}\n```\n\nPer-activation view cap is derived from the ad config\n(`max(MaxViewsPerActivation, ViewsRequiredToComplete)`, or unlimited if\n`MaxViewsPerActivation <= 0`) — it's never accidentally lower than what's\nneeded to finish the node; an explicit `Limits.PerActivationCap` on the node\nwins over both. `Limits.CooldownSeconds` (if set) rejects an early retry with\n`\"Node 'X' is on cooldown until <time>\"`.\n\n⚠ **The ad view is paid for with an ad credit, not asserted by the client.**\nAn ad node's price is an ordinary `PriceOptions` entry holding a\n`RewardedVideoCredit` cost, and only the server grants that credit — the\nAdvertising module does, on a view it confirmed itself. So the flow is: show\nthe ad through the Advertising module (which credits the player), then call\n`executeNode`, which spends the credit. There is no \"I watched it, trust me\"\nflag; a node whose credit balance is empty simply fails to pay.\n\n### Claim milestones (single, then batch)\n\n```ts\nconst one = await client.dealOffer.claimMilestone(\"Slot1\", \"milestone_1\");\nif (!one.ok) return showError(one.error); // e.g. \"Not enough earned...\", \"Milestone already claimed.\"\n\nconst batch = await client.dealOffer.claimMilestonesBatch(\"Slot1\", [\n \"milestone_2\",\n \"milestone_3\",\n \"milestone_2\", // duplicates are deduped client-side before the call\n]);\nif (!batch.ok) return showError(batch.error);\nbatch.data.ClaimedIDs; // milestone IDs that were actually claimed\nbatch.data.Rejected; // Record<milestoneID, reasonString> for ones that weren't\n```\n\nThe milestone bar's progress address is derived from the offer's _current_\nslot position (`\"{offerID}:{slotID}:c{cycleIndex}:q{queueIndex}\"`), so it\nresets to zero automatically whenever the slot advances to a new queue entry\nor cycle — there's no explicit \"reset the bar\" call. A `MilestoneClaimMode`\nof `AfterEventEnd` (or `FeaturedAfterEnd`, for `IsFeatured` milestones only)\nrejects the claim with `\"Milestone can only be claimed after the offer\nends.\"` until the activation is no longer live — see\n[references/data-model.md](references/data-model.md) for the exact\n\"activation ended\" check.\n\n### Dismiss a deal early\n\n```ts\nconst res = await client.dealOffer.dismissDeal(\"Slot1\");\nif (!res.ok) return showError(res.error);\n// slot's activation is marked Dismissed server-side; the slot's queue can\n// advance to the next offer per its Schedule/AllowDismissSkip config.\n```\n\n## Gotchas\n\n- **The node graph's real edge list is `NextNodeIDs`, not `UnlockRules`.**\n Completing a node writes `Available` state to every ID in its `NextNodeIDs`\n unconditionally; a non-root node with no runtime state yet is always\n rejected regardless of whether its own `UnlockRules` look satisfied. The\n one exception is `UnlockRules.RequiredTracks`, which genuinely does unlock\n a node the moment the relevant track crosses its threshold. Don't compute\n node availability client-side — read `ActivationState.Nodes[nodeID].Status`\n (refreshed via `getActiveDeals()` / `getUserState()`, or the latest\n `dealOffer:nodeExecuted` event).\n- **Cache isn't optimistically patched for five of the eight calls.**\n `dismissDeal`, `executeNode`, `recordShow`, `claimMilestone`, and\n `claimMilestonesBatch` mirror resource grants/consumes into the\n currency/item cache, but they do **not** patch\n `client.data.user.state?.DealOffer` themselves (per the \"optimistic slot\n patches deferred\" note in the source) — only `getUserState()` does, via\n `applyDealOffer`, which replaces the whole `DealOffer` state wholesale.\n Re-fetch `getActiveDeals()`/`getUserState()` after a mutating call if your\n UI needs the updated per-node/per-slot status rather than relying on stale\n cache reads.\n- **`executeNode`'s `Idempotent` flag matters.** When `true`, the resources in\n the response were already applied by an earlier call with the same\n `RelatedEntityID` (matched against that node's last stored execution ref) —\n the service intentionally skips both re-applying them and opening a new\n transaction. Pass your own `externalRefID` if you need a stable idempotency\n key across retries (e.g. after a network drop); otherwise the SDK mints a\n fresh `deal_exec_<slot>_<node>_<uuid>` each call — still disable the\n control while a call is in flight to guard against double-submits on the UI\n side.\n- **Milestones are a separate reward ladder from node `Grants`, and go\n through the platform-wide progression-multiplier resolver.** A node's\n `Grants` pay out immediately on that node's completion; `MilestonePoints`\n from completed nodes accumulate toward the offer's `Milestones` thresholds,\n which need their own explicit `claimMilestone`/`claimMilestonesBatch` call\n — reaching a threshold does not auto-grant its reward. The actual payout is\n the milestone's base `Rewards` scaled by the title's\n `Reward.MilestoneRewardMultiplier` progression curve if one is configured\n (same resolver Quest/CommunityChest/Referral use) — don't assume the\n claimed amount equals the milestone's raw `Rewards` field.\n- **Batch milestone claims are partial-aware, not all-or-nothing on\n rejection, and uncapped in size.** `ClaimedIDs` / `Rejected` tell you\n per-milestone outcome inside one call; an entry can be rejected on its own\n merits (not yet reached, already claimed, wrong claim-mode timing)\n independent of the others. There's no batch-size cap like Character's\n 50-item limit — send however many milestone IDs you have for one slot. The\n combined resource grant across all claimed IDs in a batch is applied as one\n atomic operation (one Mongo write, one merged `Resources.Grant` summing\n every claimed milestone's reward) — but which IDs land in `Claimed` vs\n `Rejected` is decided before that atomicity boundary.\n- **Tracks reset per activation.** `Tracks` on `ActivationState` belong to the\n current offer activation in that slot — when the slot cycles to the next\n queued offer (or the same offer re-activates later), track values start\n fresh per the offer's `DealTrackDefinition.StartValue`, they don't carry\n over. Lifetime counting instead lives in `OfferHistory`/`NodeCounts`.\n- **`AllowDismissSkip` changes what dismiss actually does.** With it `false`\n (the default), `dismissDeal` only marks the activation `Dismissed` and the\n slot's existing timer keeps ticking — the same (now-inert) offer stays\n \"current\" until it naturally expires. With it `true` (meant for\n one-time-offer slots), dismissing lets the slot advance to the next queued\n offer after `DismissSkipDelaySec` seconds.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: slot queues/schedule modes, the full node/graph shape and its\nexact unlock/exhaustion algorithm, tracks, node execution limits, the\nmilestone-bar addressing and reward-multiplier math, cost-preview\naggregation, and the idempotency/OCC mechanics behind node execution.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Deal Offer data model — reference\n\nFull shape of the config (Definitions) and player state, the node-graph\ntraversal rules, the milestone-bar addressing/math, and slot/offer resolution\nrules. All of these are **strictly typed in the SDK** — `DealOfferDefinitions`\nand every nested block are exported from `@idosgames/core`, so\n`getDefinition()` / `getSection<DealOfferDefinitions>(\"DealOffer\")` give you\nconcrete types, not `unknown`. Every schema keeps `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON). Every claim below traces to\n`IDosGamesSDK/API/Client/v2/DealOffer/{DealOffer.cs, Models/DealOfferDefinitions.cs,\nModels/UserDealOffersState.cs, Services/DealOfferHelpers.cs}` in the backend\nrepo, plus the shared `EventTokenService.cs` (Core/Event) for milestone claim\nmath.\n\n## Contents\n\n- [Config: DealOfferDefinitions](#config-dealofferdefinitions)\n- [DealSlotDefinition + queue/schedule](#dealslotdefinition--queueschedule)\n- [DealOfferDefinition](#dealofferdefinition)\n- [DealNodeDefinition + action params](#dealnodedefinition--action-params)\n- [Node unlock rules and the graph traversal algorithm](#node-unlock-rules-and-the-graph-traversal-algorithm)\n- [Tracks](#tracks)\n- [Node execution limits](#node-execution-limits)\n- [Milestones — addressing and reward math](#milestones--addressing-and-reward-math)\n- [Player state](#player-state)\n- [Slot resolution rules (GetActiveDeals)](#slot-resolution-rules-getactivedeals)\n- [Cost preview aggregation](#cost-preview-aggregation)\n- [Idempotency and OCC](#idempotency-and-occ)\n\n---\n\n## Config: DealOfferDefinitions\n\nReturned by `getDefinition()`; cached via\n`client.data.config.getSection<DealOfferDefinitions>(\"DealOffer\")`.\n\n```ts\ninterface DealOfferDefinitions {\n Slots?: Record<string, DealSlotDefinition>; // key = SlotID\n Offers?: Record<string, DealOfferDefinition>; // key = OfferID\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 37-53.\n\n---\n\n## DealSlotDefinition + queue/schedule\n\n```ts\ninterface DealSlotDefinition {\n SlotID?: string;\n Enabled?: boolean; // default true\n SortOrder?: number; // default 0\n Queue?: DealSlotQueueEntry[]; // shown in ascending Order; loops after the last\n Schedule?: ScheduleSpec; // see modes below; default Mode: \"Chained\"\n Gate?: SegmentGate; // audience gate; null = everyone\n AllowDismissSkip?: boolean; // default false — see Dismiss semantics below\n DismissSkipDelaySec?: number; // default 0\n}\n\ninterface DealSlotQueueEntry {\n Order?: number; // lower = shown earlier\n OfferID?: string; // key into DealOfferDefinitions.Offers\n DurationSec?: number; // 0 = no timer, active until fully exhausted\n DelayBeforeActivationSec?: number; // pause before this entry activates\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 62-153.\n\n### Schedule modes (`DealSlotDefinition.Schedule.Mode`)\n\nThe same `ScheduleSpec` container used by every other module (TimedEvent,\nLeaderboard, TimedBoost, ...), but Deal Offer gives each mode a distinct\nmeaning for **how the slot shows offer(s)** (`DealOfferHelpers.ResolveSlotState`,\nlines 632-649; doc comment on `Schedule` field, `DealOfferDefinitions.cs`\nlines 84-96):\n\n| Mode | Behavior |\n| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Chained` (default) | Native per-player queue: `Queue` advances for **this player** on expiry/exhaustion/dismiss of the active offer. This is the classic \"starter pack chain\" behavior. |\n| `Scheduled` / `Cyclic` / `AlwaysOn` | A **single offer** — the first `Queue` entry — active by wall clock, identically for every player (resolved via the shared `ScheduleResolver.ResolveActive`). |\n| `Triggered` | The offer becomes visible only when a source in `Schedule.ActivationTriggers` fires (e.g. a comeback offer after a board-game loss); visible for `DurationSec` of the first queue entry. |\n\nFor `Chained`, the resolver (`ResolveSlotState`, lines 632-731) walks:\n\n1. No `slotState` yet -> show queue's first entry (`IsNewActivation: true`).\n2. Active offer still alive (not timer-expired, not `Exhausted`/`Expired`, not\n a dismissed-with-`AllowDismissSkip` slot) -> keep showing it as-is.\n3. Otherwise advance: `QueueIndex + 1`; if past the end, wrap to `0` and bump\n `CycleIndex` (respecting `Schedule.Chain.MaxCycles`, `0` = unlimited, and\n `Schedule.Chain.PauseBetweenCyclesSec`, which pauses the whole slot, not just\n between offers).\n4. If the next entry has `DelayBeforeActivationSec > 0`, the wait is measured\n from whichever moment ended the previous offer (`LastDismissedAtUtc` if\n dismissed, `ExhaustedAtUtc` if exhausted, else the previous\n `ActiveOfferExpiresAtUtc`).\n\n`Triggered` slots never resolve anything until some other module's trigger hook\n(`BuildTriggeredOfferActivations`, lines 793-847) stamps an `ActiveOfferID` +\n`ActiveOfferExpiresAtUtc` on the slot state — the client cannot cause a\nTriggered offer to appear by calling deal-offer methods itself.\n\n### Dismiss semantics driven by `AllowDismissSkip`\n\n- `AllowDismissSkip: false` (default) — `dismissDeal` only marks the\n activation `Dismissed` and records history; the slot's timer (if any) keeps\n ticking and the same offer stays \"current\" (just inert) until it naturally\n expires/exhausts.\n- `AllowDismissSkip: true` — once dismissed, the resolver treats the slot as\n ready to advance (same branch as expiry/exhaustion), after\n `DismissSkipDelaySec` seconds have passed since `LastDismissedAtUtc` (`0` =\n immediately). This is meant for One-Time-Offer slots.\n\n---\n\n## DealOfferDefinition\n\n```ts\ninterface DealOfferDefinition {\n OfferID?: string;\n Version?: number; // default 1; bumped on config change\n Enabled?: boolean; // default true; false = no NEW activations (existing ones keep running)\n GraphMode?: DealOfferGraphMode; // descriptive metadata only — see below\n Name?: string;\n Description?: string;\n RootNodeIDs?: string[]; // available immediately on a fresh activation\n Nodes?: DealNodeDefinition[];\n Tracks?: DealTrackDefinition[];\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID; empty = no bar\n MilestoneClaimMode?: MilestoneClaimMode; // default \"Instant\"\n MilestoneToken?: EventTokenDefinition; // caps for milestone points; set iff Milestones non-empty\n ExhaustedWhenAllTerminalNodesCompleted?: boolean; // default true\n AssetPaths?: Record<string, string>;\n Metadata?: Record<string, string>;\n}\n\ntype DealOfferGraphMode =\n | \"Single\" // one node (Dapper Deal, Wild Sticker)\n | \"Chain\" // vertical/linear chain (Deal Aquarium)\n | \"BranchingChain\" // grid with arrows, multiple branches (Bargain Burrows)\n | \"Choice\" // pick one of N (Pick One Kennel)\n | \"MeteredChain\"; // chain gated by a progress track\n```\n\nSource: `DealOfferDefinitions.cs` lines 161-278.\n\n`GraphMode` is **purely descriptive** — it tells the title's UI/admin panel\nwhat shape the author intended, but the actual traversal is always just\n`RootNodeIDs` + `NextNodeIDs` + `UnlockRules` (+ `ChoiceGroupID` for Choice).\nDo not special-case client logic per `GraphMode`.\n\n`Enabled: false` on an offer blocks it only from being **newly activated**\n(`ComputeNodeExecutionCreate` checks `offerDef.Enabled` via the shared\n`offerDef == null || !offerDef.Enabled` guard in `ComputeNodeExecution`, line\n88, and `ResolveSlotState`'s `FindOfferDefinition`+`Enabled` checks, e.g. line\n656, 726, 749, 778) — it does not retroactively kill an activation already in\nprogress.\n\n---\n\n## DealNodeDefinition + action params\n\n```ts\ninterface DealNodeDefinition {\n NodeID?: string;\n SortOrder?: number; // default 0, UI draw order\n Type?: DealNodeType; // default \"Purchase\"\n Action?: DealNodeActionDefinition;\n Grants?: ResourceGrant; // paid out on node completion (see shouldGrant rules below)\n TrackChanges?: DealTrackChange[]; // applied on node completion\n MilestonePoints?: number; // default 0; points into the offer's milestone bar per execution\n UnlockRules?: DealNodeUnlockRules; // null = unlocked immediately (root)\n NextNodeIDs?: string[]; // nodes unlocked once THIS node completes\n ChoiceGroupID?: string; // for Choice-shaped graphs\n Limits?: LimitSpec; // null = default \"once per activation\"\n HideWhenCompleted?: boolean; // default false\n ExhaustOfferOnComplete?: boolean; // default false\n AssetPaths?: Record<string, string>;\n Metadata?: Record<string, string>;\n}\n\ntype DealNodeType = \"Purchase\" | \"FreeClaim\" | \"RewardedVideo\" | \"Info\";\n\ninterface DealNodeActionDefinition {\n Purchase?: DealPurchaseActionDefinition; // populated iff Type === \"Purchase\"\n RewardedVideo?: DealRewardedVideoActionDefinition; // populated iff Type === \"RewardedVideo\"\n}\n\ninterface DealPurchaseActionDefinition {\n StoreOfferID?: string; // if purchase routes through the Store subsystem\n BillingProductID?: string; // real-money IAP product id\n PriceOptions?: Record<string, PriceOption>; // ways to pay; used only if no StoreOfferID/BillingProductID\n UseExternalRewards?: boolean; // default true — see grant rules below\n}\n\ninterface DealRewardedVideoActionDefinition {\n AdPlacementID?: string;\n ViewsRequiredToComplete?: number; // default 1\n MaxViewsPerActivation?: number; // default 1; 0 = unlimited\n CooldownSecondsBetweenViews?: number; // default 0\n RequireServerVerification?: boolean; // default true\n GrantRewardsPerView?: boolean; // default false — see grant rules below\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 286-454.\n\n### What actually gets charged/granted per node execution\n\n`DealOfferHelpers.BuildNodeOperation` (lines 352-402), run identically on both\nthe create (bootstrap) and update paths:\n\n**Cost** — only for `Type: \"Purchase\"` nodes where `Action.Purchase.UseExternalRewards`\nis `false` **and** the selected option of `Action.Purchase.PriceOptions` has at\nleast one entry or event-token op in its `Cost.Standard`. Otherwise no cost is charged by this call at all\n(e.g. `UseExternalRewards: true` means the real-money/Store purchase flow\nhandles the charge elsewhere; this node is just an acknowledgement +\nbonus-grant step). The option's `Cost.PremiumDiscounts` ride along as-is — the\nbackend's `ResourceService.FilterByPremium` auto-picks the best matching tier\nand reduces `Consume.Standard` accordingly at charge time; the aggregated\n`Cost` preview on `ActiveDealSlotInfo` shows the **pre-discount** standard\nprice.\n\n**Grants** — `shouldGrant` is true unless the node is a `Purchase` node with\n`UseExternalRewards: true` (that combination means the _external_ system, not\nthis node, pays out the primary reward — `Grants` on such a node would be a\nbonus you'd instead need to design as `UseExternalRewards: false`, so in\npractice `UseExternalRewards: true` nodes rely on the store-purchase grant\npath). There is one more override: a `RewardedVideo` node with\n`GrantRewardsPerView: false` (the default) only grants `Grants` on the call\nthat **completes** the node (i.e. the final required view), not on every\nintermediate view — otherwise a multi-view node would overpay.\n\n**Milestone points** — independent of the above; if the offer has\n`Milestones` and the executed node's `MilestonePoints > 0`, an\n`EventTokenOperation` for `EventTokenType.DealOffer` is appended into the\n_same_ `Grant.Standard.EventTokens` list, in the same atomic transaction\n(`AppendMilestonePointsGrant`, lines 141-191).\n\n---\n\n## Node unlock rules and the graph traversal algorithm\n\n```ts\ninterface DealNodeUnlockRules {\n RequiredCompletedNodeIDs?: string[]; // ALL must be Completed\n RequiredAnyCompletedNodeIDs?: string[]; // AT LEAST ONE must be Completed\n RequiredTracks?: DealTrackRequirement[]; // offer-local track thresholds\n VisibleWhileLocked?: boolean; // default true\n}\n\ninterface DealTrackRequirement {\n TrackID?: string;\n Operator?: \"Eq\" | \"Gte\" | \"Lte\"; // default \"Gte\"\n Value?: number; // default 0\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 462-546.\n\nDespite `UnlockRules` existing independently of `NextNodeIDs` on paper, the\n**actual server algorithm resolves reachability from execution history, not\nfrom re-evaluating `UnlockRules` against arbitrary node IDs at read time.**\nConcretely (`DealOfferHelpers`, `ValidateNodeAccess` lines 1252-1286 +\n`BuildExecutionPatches`/`BuildFreshActivation` unlock blocks):\n\n- A **root node** (`RootNodeIDs`) is unlocked the instant the offer activates\n — no runtime `UserDealNodeState` entry needed; `ValidateNodeAccess` special-cases\n \"no state yet + `isRoot`\" as allowed.\n- A **non-root node with no runtime state yet** is rejected outright — \"Node\n is locked\" — **even if you construct a hypothetical case where its\n `UnlockRules` would already be satisfied.** The only way a non-root node's\n state ever gets created and set to `Available` is:\n - it appears in some other node's `NextNodeIDs` **and that other node just\n completed** (`BuildExecutionPatches` lines 896-905 / `BuildFreshActivation`\n lines 1069-1078: on completion, the engine writes `Available` state for\n every ID in `NextNodeIDs`, unconditionally — it does **not** re-check that\n node's own `UnlockRules` at that point), or\n - it has `RequiredTracks` and a `TrackChange` on some node execution just\n pushed the relevant track(s) over the threshold\n (`GetNodesUnlockedByTrack`, lines 1416-1458 — evaluated for **every**\n node in the offer whenever a track changes, regardless of `NextNodeIDs`\n membership, and only for nodes whose current status is `Locked` or absent).\n- Once a runtime state exists and is `Available`/`InProgress`, `UnlockRules`\n fields (`RequiredCompletedNodeIDs` / `RequiredAnyCompletedNodeIDs`) are\n **never consulted again** — they only produce the generic \"is locked\n (prerequisite nodes not completed)\" message on the **no-state, non-root**\n branch, purely to give a nicer error string; they do not gate anything once\n a node has been reached via `NextNodeIDs` or a track threshold.\n\n**Practical takeaway for the client:** treat `NextNodeIDs` as the _only_ real\nedge list, and `RequiredTracks`/`RequiredCompletedNodeIDs` as: (a) descriptive\nUI hints for what a locked node is waiting on, and (b) the mechanism for\ntrack-gated unlocks specifically (which do work as documented, via\n`GetNodesUnlockedByTrack`). Don't write client logic that unlocks a node\nbecause you've locally determined its `UnlockRules` are satisfied — always\nread `ActivationState.Nodes[nodeID].Status` from the server.\n\n### Choice groups\n\nOn completion of a node with `ChoiceGroupID` set, every **other** node sharing\nthat `ChoiceGroupID` is force-set to `Locked` and\n`ActivationState.SelectedChoiceNodeID` is stamped with the winner\n(`BuildExecutionPatches` lines 907-921, `BuildFreshActivation` lines\n1080-1092). This happens even if a sibling was already `Available`.\n\n### Offer exhaustion\n\nAn offer's activation flips to `Exhausted` the moment either is true\n(checked identically on create and update paths, e.g. lines 248-252 and\n318-322):\n\n- the just-completed node has `ExhaustOfferOnComplete: true`, or\n- the offer has `ExhaustedWhenAllTerminalNodesCompleted: true` (the default)\n **and** every node with an empty/absent `NextNodeIDs` (a \"terminal\" node) is\n now `Completed` or `Hidden` (`AreAllTerminalNodesCompleted`, lines\n 1374-1405).\n\n### Multi-execution nodes (`Limits.PerActivationCap` and RewardedVideo)\n\nA node is not necessarily a single-execution action:\n\n- For non-`RewardedVideo` nodes, `IsNodeCompleted` (lines 1350-1362) compares\n the new execution count against `Limits?.PerActivationCap` (default `1` if\n `Limits` is null) — so a node with e.g. `PerActivationCap: 3` requires 3\n `executeNode` calls before it flips to `Completed`, going through\n `InProgress` in between (`ComputeNodeStatus`, lines 1364-1372).\n- For `RewardedVideo` nodes, completion instead compares against\n `Action.RewardedVideo.ViewsRequiredToComplete` (default `1`), **not**\n `Limits.PerActivationCap` — see `ResolveEffectiveLimits` below.\n\n---\n\n## Tracks\n\n```ts\ninterface DealTrackDefinition {\n TrackID?: string;\n DisplayName?: string;\n StartValue?: number; // default 0\n MinValue?: number; // default 0\n MaxValue?: number; // default 0 = no maximum\n ClampToMin?: boolean; // default true\n ClampToMax?: boolean; // default true\n HiddenFromUI?: boolean; // default false\n}\n\ninterface DealTrackChange {\n TrackID?: string;\n Amount?: number; // positive = add, negative = subtract\n RespectBounds?: boolean; // default true\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 490-546; math in `ApplyTrackChange`\n(`DealOfferHelpers.cs` lines 1460-1471): `newValue = currentValue + Amount`,\nthen if `RespectBounds` and a matching `DealTrackDefinition` exists, clamp to\n`MinValue` (if `ClampToMin`) and to `MaxValue` (if `ClampToMax` **and**\n`MaxValue > 0`).\n\nTracks are seeded to `StartValue` when a fresh activation is bootstrapped\n(`BuildFreshActivation` lines 1024-1033) — they do **not** persist or carry\nover between activations of the same offer; a new activation (queue advance,\ncycle wrap, or re-trigger) always starts every track at its configured\n`StartValue`.\n\n---\n\n## Node execution limits\n\n`DealNodeDefinition.Limits` is the shared `LimitSpec` (`PerActivationCap` /\n`TotalCap` / `DailyCap`), but Deal Offer resolves an **effective** limit\nbefore checking it (`ResolveEffectiveLimits`, `DealOfferHelpers.cs` lines\n1331-1348):\n\n- **Non-RewardedVideo nodes**: `nodeDef.Limits ?? { PerActivationCap: 1 }` —\n i.e. no `Limits` block at all means \"once per activation,\" matching the\n doc comment on the config field.\n- **RewardedVideo nodes**: the per-activation cap is derived from the ad\n config, not from a generic default — `Math.max(MaxViewsPerActivation,\nViewsRequiredToComplete)` when `MaxViewsPerActivation > 0`, or `0`\n (unlimited) when `MaxViewsPerActivation <= 0`. This guarantees a multi-view\n ad node (`ViewsRequiredToComplete > 1`) can never be capped out before it's\n able to reach completion. `TotalCap`/`DailyCap` pass through from\n `nodeDef.Limits` unchanged (`0` = no cap) regardless of node type.\n\n`ValidateNodeLimits` (lines 1288-1311) checks, in order: `PerActivationCap`\nagainst the node's `ExecutionCount` this activation, `TotalCap` against\nlifetime `NodeCounts[nodeID].TotalExecutions`, and `DailyCap` against\n`NodeCounts[nodeID].DailyExecutions` (reset to `0` once `now >=\nDailyResetUtc`, which is set to the next UTC midnight after the first\nexecution of the day). Rejections surface as, respectively: `\"Execution\nlimit reached for this deal (X/Y)\"`, `\"Total execution limit reached for\nthis node\"`, `\"Daily execution limit reached for this node\"`.\n\nA `RewardedVideo` node with `CooldownSecondsBetweenViews > 0` additionally\nstamps `NextAvailableAtUtc` after each view; a call before that time returns\n`\"Node 'X' is on cooldown until <ISO time>\"`.\n\n---\n\n## Milestones — addressing and reward math\n\nMilestones use the shared `MilestoneDefinition` / `MilestoneClaimMode` (Core/Milestone\n— the same primitive as TimedEvent/Leaderboard/Quest/CommunityChest), but Deal\nOffer's progress source and instance addressing are module-specific.\n\n### Addressing (why the bar resets per activation)\n\n```\nInstanceKey = \"{slotID}:c{cycleIndex}:q{queueIndex}\"\nEventToken address = { Type: \"DealOffer\", EntityID: \"{offerID}:{InstanceKey}\" }\n```\n\nSource: `BuildDealInstanceKey` / `BuildMilestoneAddress`,\n`DealOfferHelpers.cs` lines 119-131. This address is recomputed fresh from\nthe _currently resolved_ slot position every time (`GetActiveDeals`,\n`ClaimMilestone`, `ClaimMilestonesBatch`, and node execution's\n`AppendMilestonePointsGrant` all call `BuildMilestoneAddress` with the live\n`CycleIndex`/`QueueIndex`) — so as soon as the slot advances to a new queue\nposition or cycle, the milestone bar's `EntityID` changes and the player\nstarts a **fresh** `EventTokenType.DealOffer` bucket at `TotalEarned: 0`.\nThere is no explicit \"reset\" step; it's a natural consequence of the address\nbeing derived from position, not from a monotonic counter.\n\n### Progress and reward computation\n\n- Points are earned via `DealNodeDefinition.MilestonePoints` on node\n execution, added to `EventTokenType.DealOffer`'s `Balance.TotalEarned` for\n that address (capped, if configured, by `DealOfferDefinition.MilestoneToken`'s\n `DailyEarnCap`/`MaxBalance`/`MaxPerGrant` — passed as an\n `EventTokenGrantContext`, `BuildMilestonePointsContext` lines 181-191; `null`\n `MilestoneToken` means points accrue with no global caps).\n- A milestone is \"reached\" when `TotalEarned >= milestoneDef.RequiredProgress`\n (`ComputeMilestoneClaim`, `EventTokenService.cs` line 352-353, and the\n `ReachedUnclaimedIDs` computation in `DealOffer.cs` lines 162-165).\n- The reward is **not** the milestone's flat `Rewards` — it's resolved through\n the shared `MilestoneRewardResolver.Resolve(milestoneDef, context)`\n (`DealOffer.cs` lines 652-657, 792-797), which applies the title's\n `Reward.MilestoneRewardMultiplier` progression overlay (a `RewardProgressionMultiplierSpec`\n keyed off things like board stage/rank/character level/season tier — see\n `references/_shared` `MilestoneModels.ts`) on top of the base `Rewards`. Deal\n Offer does not set a bonus-window or season-tier overlay itself, so in\n practice you get \"base reward, optionally scaled by the title-wide\n progression multiplier if one is configured on that milestone.\"\n- Batch claims combine every claimed milestone's resolved grant via\n `BonusWindowHelpers.MergeRewards` (concatenation of entries) into a single\n `ResourceOperation.Grant` before charging — so `claimMilestonesBatch`'s\n `Resources.Grant` in the response is the **sum of all claimed milestones**\n in that call, not itemized per milestone ID.\n\n### Claim mode gate (`MilestoneClaimMode`)\n\n`CheckMilestoneClaimMode` (`DealOffer.cs` lines 866-873):\n\n| Mode | Rule |\n| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| `Instant` (default) | Claimable as soon as reached — no additional gate. |\n| `AfterEventEnd` | Rejected with `\"Milestone can only be claimed after the offer ends.\"` unless the activation has ended. |\n| `FeaturedAfterEnd` | Same rejection, but only for milestones with `IsFeatured: true`; non-featured milestones under this mode claim instantly. |\n\n\"Activation ended\" (`IsActivationEnded`, lines 856-863) means: not a fresh\n`IsNewActivation`, **and** either `ActivationState.Status !== \"Active\"` or\n`now > ComputedExpiresAtUtc`. Deal Offer has **no grace window** after the\nslot advances — once the position moves on, the old bar's milestones under\n`AfterEventEnd`/`FeaturedAfterEnd` are governed by whatever `IsActivationEnded`\nevaluates to for the _newly resolved_ position, which for a genuinely-past\nactivation will read as ended.\n\n### Batch claim mechanics\n\n`ClaimMilestonesBatch` has no server-side cap on the number of milestone IDs\nper call (unlike Character's 50-item batch limit) — it just processes\nwhatever you send after de-duplication. Each requested ID is independently\nscreened for \"exists in `offerDef.Milestones`\" and the `ClaimMilestoneMode`\ngate _before_ being handed to `EventTokenService.ComputeMilestoneClaimBatch`,\nwhich re-checks `TotalEarned >= RequiredProgress` and \"not already claimed\"\nper ID (`EventTokenService.cs` lines 375-408). All rejections — not-found,\nclaim-mode-gated, not-reached, already-claimed — land in the same `Rejected`\ndictionary. The whole batch's DB write is one `PushEach` into `ClaimedIDs`\nguarded by one `Nin` filter, and the combined resource grant rides in the\nsame atomic `ResourceService.ApplyResourceOperationAtomicAsync` call — so\nclaimed milestones in one batch call either all persist or none do, but which\nIDs count as \"claimed\" vs \"rejected\" is decided before that atomicity\nboundary.\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ DealOffers: UserDealOffersState }` and\nmirrored into `client.data.user.state?.DealOffer` (note: response field is\n`DealOffers`, the cached property is `DealOffer` — singular).\n\n```ts\ninterface UserDealOffersState {\n Slots?: Record<string, UserDealSlotState>;\n OfferHistory?: Record<string, UserDealOfferHistory>; // lifetime stats, key = OfferID\n LastUpdatedUtc?: string;\n}\n\ninterface UserDealSlotState {\n SlotID?: string;\n QueueIndex?: number;\n CycleIndex?: number;\n ActiveOfferID?: string; // denormalized for convenience\n ActiveOfferStartedAtUtc?: string;\n ActiveOfferExpiresAtUtc?: string | null; // null = no timer\n ActiveOffer?: UserDealOfferActivationState;\n NextCycleStartsAtUtc?: string | null; // set while paused between Chain cycles\n LastUpdatedUtc?: string;\n DismissSkipAvailableAtUtc?: string | null;\n LastDismissedAtUtc?: string | null;\n}\n\ninterface UserDealOfferActivationState {\n OfferID?: string;\n InstanceKey?: string; // \"{slotID}:c{cycleIndex}:q{queueIndex}\" — see Milestones\n SourceOfferVersion?: number; // DealOfferDefinition.Version at activation time\n Status?: DealOfferActivationStatus; // \"Active\" | \"Exhausted\" | \"Expired\" | \"Dismissed\"\n ActivatedAtUtc?: string;\n ExhaustedAtUtc?: string | null;\n ExpiredAtUtc?: string | null;\n DismissedAtUtc?: string | null;\n ShowCount?: number; // recordShow calls this activation\n LastShownAtUtc?: string | null;\n Nodes?: Record<string, UserDealNodeState>; // key = NodeID\n Tracks?: Record<string, UserDealTrackState>; // key = TrackID\n SelectedChoiceNodeID?: string | null;\n}\n\ninterface UserDealNodeState {\n NodeID?: string;\n Status?: DealNodeRuntimeStatus; // \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\"\n ExecutionCount?: number; // executions this activation\n UnlockedAtUtc?: string | null;\n CompletedAtUtc?: string | null;\n LastExecutedAtUtc?: string | null;\n NextAvailableAtUtc?: string | null; // RewardedVideo cooldown\n LastExecutionRefID?: string | null; // last RelatedEntityID — idempotency key\n}\n\ninterface UserDealTrackState {\n TrackID?: string;\n CurrentValue?: number;\n LastUpdatedUtc?: string;\n}\n\ninterface UserDealOfferHistory {\n TotalActivations?: number;\n TotalExhausted?: number;\n TotalExpired?: number;\n TotalDismissed?: number;\n TotalShows?: number; // across ALL activations, including pre-activation show-only calls\n LastShownAtUtc?: string | null;\n LastActivatedAtUtc?: string | null;\n LastExhaustedAtUtc?: string | null;\n NodeCounts?: Record<string, UserDealNodeLifetimeCounts>; // key = NodeID\n}\n\ninterface UserDealNodeLifetimeCounts {\n TotalExecutions?: number;\n DailyExecutions?: number;\n DailyResetUtc?: string;\n}\n```\n\nSource: `UserDealOffersState.cs` in full.\n\n`OfferHistory` is cross-activation (lifetime), keyed by `OfferID` — separate\nfrom the per-activation `Nodes`/`Tracks` under `Slots[x].ActiveOffer`, which\nget wholesale replaced every time a fresh activation bootstraps\n(`BuildBootstrapPatches` does one `Set(activeOfferBase, activation)`, wiping\nthe previous activation's node/track state).\n\n---\n\n## Slot resolution rules (GetActiveDeals)\n\n`GetActiveDeals` (`DealOffer.cs` lines 95-178) walks every **enabled** slot\nwith a non-empty `Queue`, applies the slot's `Gate` (`SegmentGate`, read\nlazily — only fetches the gate-relevant player projection once, and only if\nat least one slot actually has a `Gate` configured), then calls\n`DealOfferHelpers.ResolveSlotState` per slot (the same pure resolver\n`ExecuteNode` uses internally, so reads and writes agree on \"what's active\nright now\"). Slots that fail the gate, have no enabled current/next offer, or\nare mid-pause are simply omitted from the response — there's no \"locked slot\"\nplaceholder entry.\n\nEach returned `ActiveDealSlotInfo.Milestone` is populated only if the\nresolved offer has `Milestones` — its `TotalEarned`/`ClaimedIDs` come from a\nlive `EventTokenService.ReadProgress` read against the _current_ milestone\naddress, and `ReachedUnclaimedIDs` is computed inline (defined milestones\nwhose `RequiredProgress` is met and not yet in `ClaimedIDs`).\n\n**Note on the earning-vs-spending gate split**: `ExecuteNode` re-checks the\nslot's `SegmentGate` itself, but **only** for `FreeClaim` and `RewardedVideo`\nnode types (`DealOffer.cs` lines 343-372) — `Purchase` and `Info` node\nexecutions are never blocked by the slot gate directly (a gated-out player\nsimply never sees the slot via `GetActiveDeals`, but a direct `executeNode`\ncall against a `Purchase`/`Info` node id they somehow know about isn't\nre-gated here). This mirrors the platform-wide principle: gate visibility and\nearning, not spending or claiming.\n\n---\n\n## Cost preview aggregation\n\n`ActiveDealSlotInfo.Cost` (`GetNodesCost`, `DealOfferHelpers.cs` lines\n1483-1542) is a single aggregated `ResourceConsume` built by scanning every\n`Purchase` node in the offer that has `UseExternalRewards: false` and a\nnon-empty `PriceOptions` (the node's default option is used for the preview):\n\n- `Standard` — concatenation of every such node's default option `Cost.Standard.Entries`\n and `.EventTokens` (i.e. the **sum total** if a player bought every\n purchasable node in the offer, not any single node's price).\n- `PremiumDiscounts` — unioned across nodes; when two nodes declare a discount\n for the same `(MinPremiumTier, RequiredPremiumID)` key, the **larger**\n `DiscountPercent` wins (most favorable to the player is shown).\n- `PremiumTiers` — unioned across nodes; on a duplicate `(MinPremiumTier,\nRequiredPremiumID)` key, the **first** one encountered wins (structural\n tier prices aren't summed/merged).\n\nThis is a **preview only** — the actual charge for a specific node is\ncomputed fresh, per-node, at `executeNode` time via `BuildNodeOperation`, with\nthe real discount resolution happening inside\n`ResourceService.ApplyResourceOperationAtomicAsync` → `FilterByPremium`.\n\n---\n\n## Idempotency and OCC\n\nEvery mutating call resolves its Mongo write with `ResourceService.ResolveRelatedEntityID(requestID,\nprefix)`: if the client supplied a `RelatedEntityID` (the SDK's\n`externalRefID` param on `executeNode`, or the auto-minted UUID-suffixed\ndefault), that's the key; otherwise a fallback keyed by slot/offer/node plus\nUnix-seconds guards against sub-second retries. On top of that generic\nmechanism, `ExecuteNode` layers a **domain-level idempotent replay**: if the\nsame `RelatedEntityID` matches `UserDealNodeState.LastExecutionRefID` for\nthat exact node in the current activation, the handler returns success with\n`Idempotent: true` and an empty `ResourceOperation` **without opening a Mongo\ntransaction at all** (`ComputeNodeExecution`, lines 70-79 for the\nalready-known-node fast path, and lines 227-228 in the update path) — this is\nwhy the SDK skips re-applying `Resources` into the cache when\n`data.Idempotent` is true.\n\nNode execution races (two `executeNode` calls hitting the CREATE/bootstrap\npath at once) are guarded by an optimistic-concurrency filter\n(`BuildBootstrapFilter`, lines 1189-1202) that fails the write if a live\n`Active` activation of the same offer at the same queue/cycle position\nalready exists; on that specific race the HTTP handler retries **once** by\nre-reading fresh state and resolving again (`DealOffer.cs` lines 382-472) —\nthis is transparent to the client, no special handling needed on your side\nbeyond normal retry-on-`\"connection\"`/`\"server\"` policy.\n"
|
|
8
|
+
"content": "# Deal Offer data model — reference\n\nFull shape of the config (Definitions) and player state, the node-graph\ntraversal rules, the milestone-bar addressing/math, and slot/offer resolution\nrules. All of these are **strictly typed in the SDK** — `DealOfferDefinitions`\nand every nested block are exported from `@idosgames/core`, so\n`getDefinition()` / `getSection<DealOfferDefinitions>(\"DealOffer\")` give you\nconcrete types, not `unknown`. Every schema keeps `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON). Every claim below traces to\n`IDosGamesSDK/API/Client/v2/DealOffer/{DealOffer.cs, Models/DealOfferDefinitions.cs,\nModels/UserDealOffersState.cs, Services/DealOfferHelpers.cs}` in the backend\nrepo, plus the shared `EventTokenService.cs` (Core/Event) for milestone claim\nmath.\n\n## Contents\n\n- [Config: DealOfferDefinitions](#config-dealofferdefinitions)\n- [DealSlotDefinition + queue/schedule](#dealslotdefinition--queueschedule)\n- [DealOfferDefinition](#dealofferdefinition)\n- [DealNodeDefinition + action params](#dealnodedefinition--action-params)\n- [Node unlock rules and the graph traversal algorithm](#node-unlock-rules-and-the-graph-traversal-algorithm)\n- [Tracks](#tracks)\n- [Node execution limits](#node-execution-limits)\n- [Milestones — addressing and reward math](#milestones--addressing-and-reward-math)\n- [Player state](#player-state)\n- [Slot resolution rules (GetActiveDeals)](#slot-resolution-rules-getactivedeals)\n- [Cost preview aggregation](#cost-preview-aggregation)\n- [Idempotency and OCC](#idempotency-and-occ)\n\n---\n\n## Config: DealOfferDefinitions\n\nReturned by `getDefinition()`; cached via\n`client.data.config.getSection<DealOfferDefinitions>(\"DealOffer\")`.\n\n```ts\ninterface DealOfferDefinitions {\n Slots?: Record<string, DealSlotDefinition>; // key = SlotID\n Offers?: Record<string, DealOfferDefinition>; // key = OfferID\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 37-53.\n\n---\n\n## DealSlotDefinition + queue/schedule\n\n```ts\ninterface DealSlotDefinition {\n SlotID?: string;\n Enabled?: boolean; // default true\n Identity?: DealIdentity; // display name, description, SortOrder, tags, assets\n Queue?: DealSlotQueueEntry[]; // shown in ascending Order; loops after the last\n Availability?: { Schedule?: ScheduleSpec; Gate?: SegmentGate };\n Behaviour?: { AllowDismissSkip?: boolean; DismissSkipDelaySec?: number };\n Presets?: {\n Identity?: PresetBinding;\n Availability?: PresetBinding;\n Behaviour?: PresetBinding;\n };\n}\n\ninterface DealSlotQueueEntry {\n Order?: number; // lower = shown earlier\n OfferID?: string; // key into DealOfferDefinitions.Offers\n DurationSec?: number; // 0 = no timer, active until fully exhausted\n DelayBeforeActivationSec?: number; // pause before this entry activates\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 62-153.\n\n### Schedule modes (`DealSlotDefinition.Schedule.Mode`)\n\nThe same `ScheduleSpec` container used by every other module (TimedEvent,\nLeaderboard, TimedBoost, ...), but Deal Offer gives each mode a distinct\nmeaning for **how the slot shows offer(s)** (`DealOfferHelpers.ResolveSlotState`,\nlines 632-649; doc comment on `Schedule` field, `DealOfferDefinitions.cs`\nlines 84-96):\n\n| Mode | Behavior |\n| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Chained` (default) | Native per-player queue: `Queue` advances for **this player** on expiry/exhaustion/dismiss of the active offer. This is the classic \"starter pack chain\" behavior. |\n| `Scheduled` / `Cyclic` / `AlwaysOn` | A **single offer** — the first `Queue` entry — active by wall clock, identically for every player (resolved via the shared `ScheduleResolver.ResolveActive`). |\n| `Triggered` | The offer becomes visible only when a source in `Schedule.ActivationTriggers` fires (e.g. a comeback offer after a board-game loss); visible for `DurationSec` of the first queue entry. |\n\nFor `Chained`, the resolver (`ResolveSlotState`, lines 632-731) walks:\n\n1. No `slotState` yet -> show queue's first entry (`IsNewActivation: true`).\n2. Active offer still alive (not timer-expired, not `Exhausted`/`Expired`, not\n a dismissed-with-`AllowDismissSkip` slot) -> keep showing it as-is.\n3. Otherwise advance: `QueueIndex + 1`; if past the end, wrap to `0` and bump\n `CycleIndex` (respecting `Schedule.Chain.MaxCycles`, `0` = unlimited, and\n `Schedule.Chain.PauseBetweenCyclesSec`, which pauses the whole slot, not just\n between offers).\n4. If the next entry has `DelayBeforeActivationSec > 0`, the wait is measured\n from whichever moment ended the previous offer (`LastDismissedAtUtc` if\n dismissed, `ExhaustedAtUtc` if exhausted, else the previous\n `ActiveOfferExpiresAtUtc`).\n\n`Triggered` slots never resolve anything until some other module's trigger hook\n(`BuildTriggeredOfferActivations`, lines 793-847) stamps an `ActiveOfferID` +\n`ActiveOfferExpiresAtUtc` on the slot state — the client cannot cause a\nTriggered offer to appear by calling deal-offer methods itself.\n\n### Dismiss semantics driven by `AllowDismissSkip`\n\n- `AllowDismissSkip: false` (default) — `dismissDeal` only marks the\n activation `Dismissed` and records history; the slot's timer (if any) keeps\n ticking and the same offer stays \"current\" (just inert) until it naturally\n expires/exhausts.\n- `AllowDismissSkip: true` — once dismissed, the resolver treats the slot as\n ready to advance (same branch as expiry/exhaustion), after\n `DismissSkipDelaySec` seconds have passed since `LastDismissedAtUtc` (`0` =\n immediately). This is meant for One-Time-Offer slots.\n\n---\n\n## DealOfferDefinition\n\n```ts\ninterface DealOfferDefinition {\n OfferID?: string;\n Version?: number; // default 1; bumped on config change\n Enabled?: boolean; // default true; false = no NEW activations (existing ones keep running)\n Identity?: DealIdentity;\n Graph?: {\n RootNodeIDs?: string[]; // available immediately on a fresh activation\n Nodes?: DealNodeDefinition[];\n Tracks?: DealTrackDefinition[];\n ExhaustedWhenAllTerminalNodesCompleted?: boolean; // unset = true\n };\n Milestones?: {\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID; empty = no bar\n ClaimMode?: MilestoneClaimMode; // unset = \"Instant\"\n Token?: EventTokenDefinition; // caps for milestone points\n };\n Presets?: {\n Identity?: PresetBinding;\n Graph?: PresetBinding;\n Milestones?: PresetBinding;\n };\n}\n\n/** Shared identity block — used by slot, offer and node alike. */\ninterface DealIdentity {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number; // CLIENT CONTRACT: the server never sorts anything\n Tags?: string[]; // presentation the engine never interprets\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n}\n```\n\nSource: `DealOfferDefinitions.cs`.\n\n⚠ **Slots, offers and nodes are decomposed into named BLOCKS**, each with its own independent\npreset binding into `DealOfferDefinitions.Presets` (a registry that mirrors the block names:\n`Identity`, `Availability`, `Behaviour`, `Graph`, `Milestones`, `Pricing`, `Reward`). Presets are\nresolved server-side once, when the config is materialised, so what the client receives is already\nassembled — you never merge anything yourself.\n\n⚠ Every block field is optional, and **\"unset\" is NOT the same as \"set to empty\"**: unset means\n\"inherit from the preset\", empty means \"final, inherit nothing\". Read a block through the defaults\nthe engine documents rather than assuming a value — an absent `Graph` is an offer with no nodes,\nnot a broken one.\n\n⚠ The milestone bar is ONE block on purpose. Presetting only the tier dictionary used to lose the\nclaim mode and the point caps silently, so a shared ladder came back as `Instant` and uncapped.\n\nThere is **no declared graph-mode field**. The shape of an offer (single node,\nlinear chain, branching, choice, metered) is whatever `RootNodeIDs`,\n`NextNodeIDs`, `UnlockRules` and `ChoiceGroupID` actually describe. Derive the\nlabel if your UI wants one; a declared mode could contradict how the offer\nreally behaves, which is why it was removed.\n\n`Enabled: false` on an offer blocks it only from being **newly activated** — it\ndoes not retroactively kill an activation already in progress. A disabled (or\nmissing) offer sitting in the middle of a slot's queue is **skipped**: the slot\nscans forward to the next playable entry instead of going empty. Earlier it\nzeroed the whole slot, which killed the slot permanently for everyone standing\non it — so disabling one offer is now a local edit, not a slot-wide outage.\n\n---\n\n## DealNodeDefinition + action params\n\n```ts\ninterface DealNodeDefinition {\n NodeID?: string;\n Identity?: DealIdentity; // display name, description, SortOrder, assets\n Type?: DealNodeType; // default \"Purchase\"\n Pricing?: { Options?: Record<string, PriceOption> }; // ways to pay; empty/absent = free\n Action?: DealNodeActionDefinition;\n Reward?: {\n Grants?: ResourceGrant; // paid out on node completion\n TrackChanges?: DealTrackChange[]; // applied on node completion\n MilestonePoints?: number; // unset = 0; points into the offer milestone bar per execution\n };\n UnlockRules?: DealNodeUnlockRules; // null = unlocked immediately (root)\n NextNodeIDs?: string[]; // nodes unlocked once THIS node completes\n ChoiceGroupID?: string; // for Choice-shaped graphs\n Limits?: LimitSpec; // null = default \"once per activation\"\n HideWhenCompleted?: boolean; // default false\n ExhaustOfferOnComplete?: boolean; // default false\n AssetPaths?: Record<string, string>;\n Metadata?: Record<string, string>;\n}\n\ntype DealNodeType = \"Purchase\" | \"FreeClaim\" | \"RewardedVideo\" | \"Info\";\n\ninterface DealNodeActionDefinition {\n RewardedVideo?: DealRewardedVideoActionDefinition; // populated iff Type === \"RewardedVideo\"\n}\n\n// Progression of an ad node only — the PRICE is not here, it is the node's own\n// PriceOptions, and the pause between views is the shared Limits.CooldownSeconds.\ninterface DealRewardedVideoActionDefinition {\n ViewsRequiredToComplete?: number; // default 1\n MaxViewsPerActivation?: number; // default 1; 0 = unlimited\n GrantRewardsPerView?: boolean; // default false — see grant rules below\n}\n```\n\nSource: `DealOfferDefinitions.cs`.\n\n**Price lives on the node, for every node type.** A node paid in resources, one\npaid in a store product, one paid with an ad credit and a free one differ only\nby what is inside `PriceOptions` — there is no separate purchase block and no\nflag that switches charging off. An unset/empty `PriceOptions` means free.\n\n### What actually gets charged/granted per node execution\n\n`DealOfferHelpers.BuildNodeOperation` (lines 352-402), run identically on both\nthe create (bootstrap) and update paths:\n\n**Cost** — the selected option of the node's own `PriceOptions`, whatever the\nnode's `Type` is; if it has no entry and no event-token op in its\n`Cost.Standard`, the execution is simply free. The option's\n`Cost.PremiumDiscounts` ride along as-is — the\nbackend's `ResourceService.FilterByPremium` auto-picks the best matching tier\nand reduces `Consume.Standard` accordingly at charge time; the aggregated\n`Cost` preview on `ActiveDealSlotInfo` shows the **pre-discount** standard\nprice.\n\n**Grants** — always paid out on the call that completes the node. The one\noverride: a `RewardedVideo` node with\n`GrantRewardsPerView: false` (the default) only grants `Grants` on the call\nthat **completes** the node (i.e. the final required view), not on every\nintermediate view — otherwise a multi-view node would overpay.\n\n**Milestone points** — independent of the above; if the offer has\n`Milestones` and the executed node's `MilestonePoints > 0`, an\n`EventTokenOperation` for `EventTokenType.DealOffer` is appended into the\n_same_ `Grant.Standard.EventTokens` list, in the same atomic transaction\n(`AppendMilestonePointsGrant`, lines 141-191).\n\n---\n\n## Node unlock rules and the graph traversal algorithm\n\n```ts\ninterface DealNodeUnlockRules {\n RequiredCompletedNodeIDs?: string[]; // ALL must be Completed\n RequiredAnyCompletedNodeIDs?: string[]; // AT LEAST ONE must be Completed\n RequiredTracks?: DealTrackRequirement[]; // offer-local track thresholds\n VisibleWhileLocked?: boolean; // default true\n}\n\ninterface DealTrackRequirement {\n TrackID?: string;\n Operator?: \"Eq\" | \"Gte\" | \"Lte\"; // default \"Gte\"\n Value?: number; // default 0\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 462-546.\n\nA node becomes playable one of two ways, and both are real:\n\n- A **root node** (`RootNodeIDs`) is playable the instant the offer activates —\n no runtime `UserDealNodeState` entry needed.\n- A node **pushed** by another node's `NextNodeIDs`: on completion the engine\n writes `Available` state for every ID listed there, unconditionally (it does\n not re-check that node's own `UnlockRules` at that point). Track thresholds\n do the same through `GetNodesUnlockedByTrack`, which is evaluated for every\n node in the offer whenever a track changes, regardless of `NextNodeIDs`\n membership.\n- A node with **no runtime state and no push** is now evaluated against its own\n `UnlockRules`: `RequiredCompletedNodeIDs` (all must be completed in this\n activation), `RequiredAnyCompletedNodeIDs` (at least one) and\n `RequiredTracks` (all thresholds met). Satisfied — it plays; not satisfied —\n it is refused with `\"... is locked (prerequisite nodes not completed)\"` or\n `\"... (track requirements not met)\"`. A non-root node that declares **no\n rules at all** is still refused: nothing could ever open it except a push.\n\n⚠ This is a behaviour change. Previously the rules were only tested for\nnon-emptiness, so a node that declared any prerequisite was locked **forever**;\noffers built on `RequiredCompletedNodeIDs` without a matching `NextNodeIDs`\nedge simply did not work. If you designed around that, re-check your graphs.\n\n⚠ A node whose status is `Hidden` counts as **completed** for prerequisite\npurposes — `HideWhenCompleted` sets that status after the node was played, and\nnot counting it would lock every node whose prerequisite is configured to hide\nitself. A missing track counts as `0`, its starting value.\n\nOnce a runtime state exists, its `Status` is the authority and `UnlockRules`\nare not consulted again.\n\n**Practical takeaway for the client:** still read\n`ActivationState.Nodes[nodeID].Status` from the server rather than unlocking\nthings locally — but `UnlockRules` are now a genuine gate, not just a UI hint.\n\n### Choice groups\n\nOn completion of a node with `ChoiceGroupID` set, every **other** node sharing\nthat `ChoiceGroupID` is force-set to `Locked` and\n`ActivationState.SelectedChoiceNodeID` is stamped with the winner\n(`BuildExecutionPatches` lines 907-921, `BuildFreshActivation` lines\n1080-1092). This happens even if a sibling was already `Available`.\n\n### Offer exhaustion\n\nAn offer's activation flips to `Exhausted` the moment either is true\n(checked identically on create and update paths, e.g. lines 248-252 and\n318-322):\n\n- the just-completed node has `ExhaustOfferOnComplete: true`, or\n- the offer has `ExhaustedWhenAllTerminalNodesCompleted: true` (the default)\n **and** every node with an empty/absent `NextNodeIDs` (a \"terminal\" node) is\n now `Completed` or `Hidden` (`AreAllTerminalNodesCompleted`, lines\n 1374-1405).\n\n### Multi-execution nodes (`Limits.PerActivationCap` and RewardedVideo)\n\nA node is not necessarily a single-execution action:\n\n- For non-`RewardedVideo` nodes, `IsNodeCompleted` (lines 1350-1362) compares\n the new execution count against `Limits?.PerActivationCap` (default `1` if\n `Limits` is null) — so a node with e.g. `PerActivationCap: 3` requires 3\n `executeNode` calls before it flips to `Completed`, going through\n `InProgress` in between (`ComputeNodeStatus`, lines 1364-1372).\n- For `RewardedVideo` nodes, completion instead compares against\n `Action.RewardedVideo.ViewsRequiredToComplete` (default `1`), **not**\n `Limits.PerActivationCap` — see `ResolveEffectiveLimits` below.\n\n---\n\n## Tracks\n\n```ts\ninterface DealTrackDefinition {\n TrackID?: string;\n DisplayName?: string;\n StartValue?: number; // default 0\n MinValue?: number; // default 0\n MaxValue?: number; // default 0 = no maximum\n ClampToMin?: boolean; // default true\n ClampToMax?: boolean; // default true\n HiddenFromUI?: boolean; // default false\n}\n\ninterface DealTrackChange {\n TrackID?: string;\n Amount?: number; // positive = add, negative = subtract\n RespectBounds?: boolean; // default true\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 490-546; math in `ApplyTrackChange`\n(`DealOfferHelpers.cs` lines 1460-1471): `newValue = currentValue + Amount`,\nthen if `RespectBounds` and a matching `DealTrackDefinition` exists, clamp to\n`MinValue` (if `ClampToMin`) and to `MaxValue` (if `ClampToMax` **and**\n`MaxValue > 0`).\n\nTracks are seeded to `StartValue` when a fresh activation is bootstrapped\n(`BuildFreshActivation` lines 1024-1033) — they do **not** persist or carry\nover between activations of the same offer; a new activation (queue advance,\ncycle wrap, or re-trigger) always starts every track at its configured\n`StartValue`.\n\n---\n\n## Node execution limits\n\n`DealNodeDefinition.Limits` is the shared `LimitSpec` (`PerActivationCap` /\n`TotalCap` / `DailyCap`), but Deal Offer resolves an **effective** limit\nbefore checking it (`ResolveEffectiveLimits`, `DealOfferHelpers.cs` lines\n1331-1348):\n\n- **Non-RewardedVideo nodes**: `nodeDef.Limits ?? { PerActivationCap: 1 }` —\n i.e. no `Limits` block at all means \"once per activation,\" matching the\n doc comment on the config field.\n- **RewardedVideo nodes**: the per-activation cap is derived from the ad\n config, not from a generic default — `Math.max(MaxViewsPerActivation,\nViewsRequiredToComplete)` when `MaxViewsPerActivation > 0`, or `0`\n (unlimited) when `MaxViewsPerActivation <= 0`. This guarantees a multi-view\n ad node (`ViewsRequiredToComplete > 1`) can never be capped out before it's\n able to reach completion — **unless the node declares its own non-zero\n `Limits.PerActivationCap`, which wins**. `TotalCap`/`DailyCap` pass through\n from `nodeDef.Limits` unchanged (`0` = no cap) regardless of node type.\n\n`ValidateNodeLimits` (lines 1288-1311) checks, in order: `PerActivationCap`\nagainst the node's `ExecutionCount` this activation, `TotalCap` against\nlifetime `NodeCounts[nodeID].TotalExecutions`, and `DailyCap` against\n`NodeCounts[nodeID].DailyExecutions` (reset to `0` once `now >=\nDailyResetUtc`, which is set to the next UTC midnight after the first\nexecution of the day). Rejections surface as, respectively: `\"Execution\nlimit reached for this deal (X/Y)\"`, `\"Total execution limit reached for\nthis node\"`, `\"Daily execution limit reached for this node\"`.\n\nA node with `Limits.CooldownSeconds > 0` additionally stamps\n`NextAvailableAtUtc` after each execution; a call before that time returns\n`\"Node 'X' is on cooldown until <ISO time>\"`. (This is the shared `LimitSpec`\naxis — ad nodes no longer carry a cooldown field of their own.)\n\n---\n\n## Milestones — addressing and reward math\n\nMilestones use the shared `MilestoneDefinition` / `MilestoneClaimMode` (Core/Milestone\n— the same primitive as TimedEvent/Leaderboard/Quest/CommunityChest), but Deal\nOffer's progress source and instance addressing are module-specific.\n\n### Addressing (why the bar resets per activation)\n\n```\nInstanceKey = \"{slotID}:c{cycleIndex}:q{queueIndex}\"\nEventToken address = { Type: \"DealOffer\", EntityID: \"{offerID}:{InstanceKey}\" }\n```\n\nSource: `BuildDealInstanceKey` / `BuildMilestoneAddress`,\n`DealOfferHelpers.cs` lines 119-131. This address is recomputed fresh from\nthe _currently resolved_ slot position every time (`GetActiveDeals`,\n`ClaimMilestone`, `ClaimMilestonesBatch`, and node execution's\n`AppendMilestonePointsGrant` all call `BuildMilestoneAddress` with the live\n`CycleIndex`/`QueueIndex`) — so as soon as the slot advances to a new queue\nposition or cycle, the milestone bar's `EntityID` changes and the player\nstarts a **fresh** `EventTokenType.DealOffer` bucket at `TotalEarned: 0`.\nThere is no explicit \"reset\" step; it's a natural consequence of the address\nbeing derived from position, not from a monotonic counter.\n\n### Progress and reward computation\n\n- Points are earned via `DealNodeDefinition.MilestonePoints` on node\n execution, added to `EventTokenType.DealOffer`'s `Balance.TotalEarned` for\n that address (capped, if configured, by the milestone block's `Token`\n `DailyEarnCap`/`MaxBalance`/`MaxPerGrant` — passed as an\n `EventTokenGrantContext`, `BuildMilestonePointsContext` lines 181-191; `null`\n `Token` means points accrue with no global caps).\n- A milestone is \"reached\" when `TotalEarned >= milestoneDef.RequiredProgress`\n (`ComputeMilestoneClaim`, `EventTokenService.cs` line 352-353, and the\n `ReachedUnclaimedIDs` computation in `DealOffer.cs` lines 162-165).\n- The reward is **not** the milestone's flat `Rewards` — it's resolved through\n the shared `MilestoneRewardResolver.Resolve(milestoneDef, context)`\n (`DealOffer.cs` lines 652-657, 792-797), which applies the title's\n `Reward.MilestoneRewardMultiplier` progression overlay (a `RewardProgressionMultiplierSpec`\n keyed off things like board stage/rank/character level/season tier — see\n `references/_shared` `MilestoneModels.ts`) on top of the base `Rewards`. Deal\n Offer does not set a bonus-window or season-tier overlay itself, so in\n practice you get \"base reward, optionally scaled by the title-wide\n progression multiplier if one is configured on that milestone.\"\n- Batch claims combine every claimed milestone's resolved grant via\n `BonusWindowHelpers.MergeRewards` (concatenation of entries) into a single\n `ResourceOperation.Grant` before charging — so `claimMilestonesBatch`'s\n `Resources.Grant` in the response is the **sum of all claimed milestones**\n in that call, not itemized per milestone ID.\n\n### Claim mode gate (`MilestoneClaimMode`)\n\n`CheckMilestoneClaimMode` (`DealOffer.cs` lines 866-873):\n\n| Mode | Rule |\n| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| `Instant` (default) | Claimable as soon as reached — no additional gate. |\n| `AfterEventEnd` | Rejected with `\"Milestone can only be claimed after the offer ends.\"` unless the activation has ended. |\n| `FeaturedAfterEnd` | Same rejection, but only for milestones with `IsFeatured: true`; non-featured milestones under this mode claim instantly. |\n\n\"Activation ended\" (`IsActivationEnded`, lines 856-863) means: not a fresh\n`IsNewActivation`, **and** either `ActivationState.Status !== \"Active\"` or\n`now > ComputedExpiresAtUtc`. Deal Offer has **no grace window** after the\nslot advances — once the position moves on, the old bar's milestones under\n`AfterEventEnd`/`FeaturedAfterEnd` are governed by whatever `IsActivationEnded`\nevaluates to for the _newly resolved_ position, which for a genuinely-past\nactivation will read as ended.\n\n### Batch claim mechanics\n\n`ClaimMilestonesBatch` has no server-side cap on the number of milestone IDs\nper call (unlike Character's 50-item batch limit) — it just processes\nwhatever you send after de-duplication. Each requested ID is independently\nscreened for \"exists in `offerDef.Milestones`\" and the `ClaimMilestoneMode`\ngate _before_ being handed to `EventTokenService.ComputeMilestoneClaimBatch`,\nwhich re-checks `TotalEarned >= RequiredProgress` and \"not already claimed\"\nper ID (`EventTokenService.cs` lines 375-408). All rejections — not-found,\nclaim-mode-gated, not-reached, already-claimed — land in the same `Rejected`\ndictionary. The whole batch's DB write is one `PushEach` into `ClaimedIDs`\nguarded by one `Nin` filter, and the combined resource grant rides in the\nsame atomic `ResourceService.ApplyResourceOperationAtomicAsync` call — so\nclaimed milestones in one batch call either all persist or none do, but which\nIDs count as \"claimed\" vs \"rejected\" is decided before that atomicity\nboundary.\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ DealOffers: UserDealOffersState }` and\nmirrored into `client.data.user.state?.DealOffer` (note: response field is\n`DealOffers`, the cached property is `DealOffer` — singular).\n\n```ts\ninterface UserDealOffersState {\n Slots?: Record<string, UserDealSlotState>;\n OfferHistory?: Record<string, UserDealOfferHistory>; // lifetime stats, key = OfferID\n LastUpdatedUtc?: string;\n}\n\ninterface UserDealSlotState {\n SlotID?: string;\n QueueIndex?: number;\n CycleIndex?: number;\n ActiveOfferID?: string; // denormalized for convenience\n ActiveOfferStartedAtUtc?: string;\n ActiveOfferExpiresAtUtc?: string | null; // null = no timer\n ActiveOffer?: UserDealOfferActivationState;\n NextCycleStartsAtUtc?: string | null; // set while paused between Chain cycles\n LastUpdatedUtc?: string;\n DismissSkipAvailableAtUtc?: string | null;\n LastDismissedAtUtc?: string | null;\n}\n\ninterface UserDealOfferActivationState {\n OfferID?: string;\n InstanceKey?: string; // \"{slotID}:c{cycleIndex}:q{queueIndex}\" — see Milestones\n SourceOfferVersion?: number; // DealOfferDefinition.Version at activation time\n Status?: DealOfferActivationStatus; // \"Active\" | \"Exhausted\" | \"Expired\" | \"Dismissed\"\n ActivatedAtUtc?: string;\n ExhaustedAtUtc?: string | null;\n ExpiredAtUtc?: string | null;\n DismissedAtUtc?: string | null;\n ShowCount?: number; // recordShow calls this activation\n LastShownAtUtc?: string | null;\n Nodes?: Record<string, UserDealNodeState>; // key = NodeID\n Tracks?: Record<string, UserDealTrackState>; // key = TrackID\n SelectedChoiceNodeID?: string | null;\n}\n\ninterface UserDealNodeState {\n NodeID?: string;\n Status?: DealNodeRuntimeStatus; // \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\"\n ExecutionCount?: number; // executions this activation\n UnlockedAtUtc?: string | null;\n CompletedAtUtc?: string | null;\n LastExecutedAtUtc?: string | null;\n NextAvailableAtUtc?: string | null; // RewardedVideo cooldown\n LastExecutionRefID?: string | null; // last RelatedEntityID — idempotency key\n}\n\ninterface UserDealTrackState {\n TrackID?: string;\n CurrentValue?: number;\n LastUpdatedUtc?: string;\n}\n\ninterface UserDealOfferHistory {\n TotalActivations?: number;\n TotalExhausted?: number;\n TotalExpired?: number;\n TotalDismissed?: number;\n TotalShows?: number; // across ALL activations, including pre-activation show-only calls\n LastShownAtUtc?: string | null;\n LastActivatedAtUtc?: string | null;\n LastExhaustedAtUtc?: string | null;\n NodeCounts?: Record<string, UserDealNodeLifetimeCounts>; // key = NodeID\n}\n\ninterface UserDealNodeLifetimeCounts {\n TotalExecutions?: number;\n DailyExecutions?: number;\n DailyResetUtc?: string;\n}\n```\n\nSource: `UserDealOffersState.cs` in full.\n\n`OfferHistory` is cross-activation (lifetime), keyed by `OfferID` — separate\nfrom the per-activation `Nodes`/`Tracks` under `Slots[x].ActiveOffer`, which\nget wholesale replaced every time a fresh activation bootstraps\n(`BuildBootstrapPatches` does one `Set(activeOfferBase, activation)`, wiping\nthe previous activation's node/track state).\n\n---\n\n## Slot resolution rules (GetActiveDeals)\n\n`GetActiveDeals` (`DealOffer.cs` lines 95-178) walks every **enabled** slot\nwith a non-empty `Queue`, applies the slot's `Gate` (`SegmentGate`, read\nlazily — only fetches the gate-relevant player projection once, and only if\nat least one slot actually has a `Gate` configured), then calls\n`DealOfferHelpers.ResolveSlotState` per slot (the same pure resolver\n`ExecuteNode` uses internally, so reads and writes agree on \"what's active\nright now\"). Slots that fail the gate, have no enabled current/next offer, or\nare mid-pause are simply omitted from the response — there's no \"locked slot\"\nplaceholder entry.\n\nEach returned `ActiveDealSlotInfo.Milestone` is populated only if the\nresolved offer has `Milestones` — its `TotalEarned`/`ClaimedIDs` come from a\nlive `EventTokenService.ReadProgress` read against the _current_ milestone\naddress, and `ReachedUnclaimedIDs` is computed inline (defined milestones\nwhose `RequiredProgress` is met and not yet in `ClaimedIDs`).\n\n**Note on the earning-vs-spending gate split**: `ExecuteNode` re-checks the\nslot's `SegmentGate` itself, but **only** for `FreeClaim` and `RewardedVideo`\nnode types (`DealOffer.cs` lines 343-372) — `Purchase` and `Info` node\nexecutions are never blocked by the slot gate directly (a gated-out player\nsimply never sees the slot via `GetActiveDeals`, but a direct `executeNode`\ncall against a `Purchase`/`Info` node id they somehow know about isn't\nre-gated here). This mirrors the platform-wide principle: gate visibility and\nearning, not spending or claiming.\n\n---\n\n## Cost preview aggregation\n\n`ActiveDealSlotInfo.Cost` (`GetNodesCost`, `DealOfferHelpers.cs`) is a single\naggregated `ResourceConsume` built by scanning every node in the offer that has\na non-empty `PriceOptions`, whatever its `Type` (the node's **default** option\nis used for the preview):\n\n⚠ The preview uses the default option, but execution charges the **selected**\none. In a title with per-platform prices those two differ by construction —\nthe slot summary has neither a player nor a platform — so treat `Cost` as an\nindication and read the exact price from the execute response.\n\n- `Standard` — concatenation of every such node's default option `Cost.Standard.Entries`\n and `.EventTokens` (i.e. the **sum total** if a player bought every\n purchasable node in the offer, not any single node's price).\n- `PremiumDiscounts` — unioned across nodes; when two nodes declare a discount\n for the same `(MinPremiumTier, RequiredPremiumID)` key, the **larger**\n `DiscountPercent` wins (most favorable to the player is shown).\n- `PremiumTiers` — unioned across nodes; on a duplicate `(MinPremiumTier,\nRequiredPremiumID)` key, the **first** one encountered wins (structural\n tier prices aren't summed/merged).\n\nThis is a **preview only** — the actual charge for a specific node is\ncomputed fresh, per-node, at `executeNode` time via `BuildNodeOperation`, with\nthe real discount resolution happening inside\n`ResourceService.ApplyResourceOperationAtomicAsync` → `FilterByPremium`.\n\n---\n\n## Idempotency and OCC\n\nEvery mutating call resolves its Mongo write with `ResourceService.ResolveRelatedEntityID(requestID,\nprefix)`: if the client supplied a `RelatedEntityID` (the SDK's\n`externalRefID` param on `executeNode`, or the auto-minted UUID-suffixed\ndefault), that's the key; otherwise a fallback keyed by slot/offer/node plus\nUnix-seconds guards against sub-second retries. On top of that generic\nmechanism, `ExecuteNode` layers a **domain-level idempotent replay**: if the\nsame `RelatedEntityID` matches `UserDealNodeState.LastExecutionRefID` for\nthat exact node in the current activation, the handler returns success with\n`Idempotent: true` and an empty `ResourceOperation` **without opening a Mongo\ntransaction at all** (`ComputeNodeExecution`, lines 70-79 for the\nalready-known-node fast path, and lines 227-228 in the update path) — this is\nwhy the SDK skips re-applying `Resources` into the cache when\n`data.Idempotent` is true.\n\nNode execution races (two `executeNode` calls hitting the CREATE/bootstrap\npath at once) are guarded by an optimistic-concurrency filter\n(`BuildBootstrapFilter`, lines 1189-1202) that fails the write if a live\n`Active` activation of the same offer at the same queue/cycle position\nalready exists; on that specific race the HTTP handler retries **once** by\nre-reading fresh state and resolving again (`DealOffer.cs` lines 382-472) —\nthis is transparent to the client, no special handling needed on your side\nbeyond normal retry-on-`\"connection\"`/`\"server\"` policy.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-compose-modules",
|
|
3
|
-
"description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the cross-module
|
|
4
|
-
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n cross-module
|
|
3
|
+
"description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the shared HUD (game-hud, sharedUi roles, shouldDraw), typed cross-module events (defineTopic/shape), or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module).",
|
|
4
|
+
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n shared HUD (game-hud, sharedUi roles, shouldDraw), typed cross-module events (defineTopic/shape),\n or host-level shared state work. Builds on idosgames-getting-started\n (scaffolding) and idosgames-module-contract (a single module).\n---\n\n# Composing modules into one game\n\nThe whole point of the architecture: a developer plugs in several modules and merges them. The host\nhandles coexistence — you just register the modules and (optionally) wire shared state.\n\n## Register several modules\n\n```ts\n// src/modules.ts\nexport const modules: Module[] = [\n boardGameModule, // Three tycoon\n idleRpgModule, // Phaser idle\n];\n```\n\nEach game module registers a route → the host renders a **nav-bar** (`🎲 Board | ⚔️ Idle RPG`) and\n**mode-switches**: only the active mode's scene is mounted and ticking; the rest are suspended\n(their RAF stops). This is why two different engines (Three + Phaser) can live in one project — they\nnever render at the same time. Modules of the same engine family may later share a renderer\n(composition), but the module code doesn't change either way.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode — same `client.currency`, same\n cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files. The client and the event bus are the seams between them —\n which is what keeps a module reusable in another game. Code several of THIS game's modules need\n (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a module\n (see idosgames-project-structure).\n\n## Shared chrome: roles + game-hud\n\nBalances, the crypto wallet, the status line and \"Log out\" / player ID are **shared-UI roles**\n(`currency-bar | wallet | status | account`). A module that draws one for EVERY mode declares it\nstatically on the module object:\n\n```ts\nexport const gameHudModule = defineModule({\n id: \"game-hud\",\n meta: { name: \"Game HUD\", type: \"app\", engine: \"dom\" },\n sharedUi: { provides: [\"currency-bar\", \"wallet\", \"status\", \"account\"] },\n setup(ctx) {\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: GameHud,\n });\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider in\n`src/modules.ts` wins, a conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`.\nA template draws its own copy only while nobody took the role. So:\n\n- **Mixing two or more templates → install `game-hud`** (catalog, `type: feature`). Do NOT edit the\n templates to remove their wallets/balances/status: they hide those themselves, and an edit would\n mark them customized (no more catalog updates). The platform's AI editor installs game-hud\n automatically when a project starts from two or more templates.\n- A template installed alone stays a complete game — no owner, so it draws everything.\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n- When a module takes `account`, the host stops drawing its own bottom-left Log out / ID row.\n- A custom HUD replaces game-hud the same way: declare the roles, draw them.\n\n**Layout is the host's job.** It measures the `hud` slot and the nav and moves the `overlay` and\n`sidebar` layers between them, so a template's overlay never slides under the HUD or the nav — no\ntemplate changes. Anything drawn outside those layers (a scene's own DOM HUD) uses the CSS\nvariables on the host root: `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)` (voxelcraft's hotbar\ndoes this) and `--idos-safe-top`. Both are 0 with no HUD and a single mode. HUD panels are wrapped\nin `display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let\nclicks through to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference merge\n\n`host-starter` + `board-game` + `idle-rpg` + `game-hud`:\n\n- nav-bar switches modes; one engine scene mounted at a time;\n- game-hud is pinned across all modes: one balance bar, one wallet, one status line, the account\n chip — the templates' own copies are hidden, and both modes' status messages land in the HUD;\n- board's footer and idle's dock sit between the HUD and the nav;\n- currency changed in one mode is immediately visible in the others; levelling a hero in Idle RPG\n shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-getting-started",
|
|
3
|
-
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
|
|
4
|
-
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`).\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them
|
|
3
|
+
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as the project grows). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
|
|
4
|
+
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them. Mixing two or more templates → also add `game-hud`: one balance bar, wallet and status line\n for every mode, and the templates hide their own copies by themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-module-contract",
|
|
3
|
-
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.",
|
|
4
|
-
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (cross-module bus) · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
3
|
+
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
+
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID). A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`).\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "idosgames-project-structure",
|
|
3
|
+
"description": "The layout standard for an iDosGames game project (host shell + composable modules) and how it grows without turning into a mess: where a new feature goes (a catalog module, an existing module, or a new module), the folder layout inside a module, module boundaries (no cross-module imports), the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated, idos.modules.lock.json and customized catalog modules, file size, and project documentation (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing src/shared/, moving code between files or folders, adding a system that spans several modules, or writing project documentation.",
|
|
4
|
+
"content": "---\nname: idosgames-project-structure\ndescription: >-\n The layout standard for an iDosGames game project (host shell + composable modules) and how it\n grows without turning into a mess: where a new feature goes (a catalog module, an existing module,\n or a new module), the folder layout inside a module, module boundaries (no cross-module imports),\n the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated,\n idos.modules.lock.json and customized catalog modules, file size, and project documentation\n (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing\n src/shared/, moving code between files or folders, adding a system that spans several modules, or\n writing project documentation.\n---\n\n# Project structure (iDosGames game projects)\n\n## The project at a glance\n\n```\nIDOS.md project guide every agent reads first — short, evergreen\nAGENTS.md, CLAUDE.md pointers to IDOS.md for external tools (Cursor, Codex, Claude Code…)\nidos.modules.lock.json platform-owned: catalog modules, their versions and file fingerprints\ndocs/feature-history/ one file per game system + README.md index\npackage.json · vite.config.ts · tsconfig.json · index.html\nsrc/\n main.tsx the host: ONE SDK client + mountHost(...)\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts · env.ts title / build-key resolution\n LoginScreen.tsx the login screen — restyle freely\n shared/ optional: code two or more of THIS game's modules need\n ui/ types/ utils/\n modules/\n <id>/ one folder per module: catalog templates, catalog features, the game's own\n```\n\nEverything the game does lives in a module. Ready-made catalog modules and the game's own modules\nshare the one `src/modules/` folder — where a module came from is recorded in\n`idos.modules.lock.json`, not in its path, because a catalog template becomes the creator's code the\nmoment they start changing it. `src/shared/` is the one place for code several of the game's own\nmodules use; there is no project-level `utils/` or `components/` besides it.\n\n## Where does a new feature go?\n\nDecide in this order:\n\n1. **The catalog already has it** → install that module (the AI editor's InstallModule, or\n `get_module` over MCP) and adapt it. Don't rebuild what exists.\n2. **It extends an existing module's gameplay** (a new tile type in the board game, a new enemy in\n the idle RPG) → change that module, inside its folder.\n3. **It is its own mode, screen or system** (a shop, a clan screen, a mini-game, a quest board) → a\n **new module** `src/modules/<feature-id>/`.\n4. **It is chrome for every mode.** Balances, the wallet, the status line, Log out / player ID are\n shared-UI roles → a module that **provides** them (`sharedUi: { provides: [...] }` in module.ts):\n install `game-hud` from the catalog, or change it; templates hide their own copies by\n themselves. Other UI every mode shows (a global menu) → a panel with `activeOnly: false` in a\n no-scene, no-route module (`type: \"app\"`, `engine: \"dom\"`). See **idosgames-compose-modules**.\n5. **It is code two or more of the game's modules need** (a shared type, the game's UI kit, a\n formatting helper) → `src/shared/` (see below).\n\nBetween 2 and 3: if it would get its own nav tab, or could be switched off on its own, it is its own\nmodule.\n\n## Inside a module\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ id, meta, setup(ctx) { … } })\n module.meta.json manifest: type, summary, provides, tags, version, author\n components/ React panels and UI pieces\n game/ engine and simulation (engine subfolders are fine: game/phaser/, game/three/)\n data/ static tables and tuning (levels, tiles, item lists)\n react/ hooks, contexts, the scene↔panel controller bridge\n```\n\n- The folder id is kebab-case `[a-z0-9-]`, at most 64 characters. The export name is derived from it\n — `daily-quests` → `dailyQuestsModule` — and the platform registers the module by that exact name.\n- Create only the folders you need; a small module can be `index.ts` + `module.ts` + one component.\n- `voxelcraft` is a ported vanilla game with its own layout — leave it as it is. New modules follow\n the layout above.\n\n### module.meta.json\n\n```json\n{\n \"id\": \"daily-quests\",\n \"type\": \"feature\",\n \"summary\": \"One-line pitch shown in the Modules dialog.\",\n \"description\": \"What it does, in a few sentences.\",\n \"provides\": [\"daily quest board\", \"streak rewards\"],\n \"tags\": [\"quests\", \"retention\"],\n \"version\": \"0.1.0\",\n \"author\": { \"name\": \"…\" },\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"daily-quests:quest-completed@1\",\n \"when\": \"a quest's reward was claimed\",\n \"payload\": { \"questId\": \"string\" }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"progress 'level a hero' quests\"\n }\n ]\n }\n}\n```\n\n`type` is `template` (a complete game to start from) or `feature` (a capability added to a game).\n`events` declares every `defineTopic(...)` the module uses — its own topics under `emits` (with the\nsame payload descriptor it passes to `shape()`), other modules' under `listens`; omit it when the\nmodule has none.\n`provides` is what an agent matches a request against — keep it accurate. Every module carries this\nfile, the game's own included: it is how a module shows up in the Modules dialog, and what lets it be\npublished to the shared catalog for other creators later.\n\n### Register it\n\n`src/modules.ts` has exactly this shape — imports plus the array, nothing else:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nimport { dailyQuestsModule } from \"./modules/daily-quests\";\n\nexport const modules: Module[] = [boardGameModule, dailyQuestsModule];\n```\n\nThe platform regenerates this file whenever modules are installed or removed; any other code in it is\nlost. Array order is mount order (and nav order).\n\n## Module boundaries\n\nA module imports only:\n\n- files inside its own folder;\n- `src/shared/` — the game's shared code;\n- npm packages (`@idosgames/*`, `react`, `three`, `phaser`, …).\n\nNever another module's files (`../board-game/…`) and never host files (`../../main`, `../../config`).\nModules are mixed into different games — a module that reaches into its neighbours breaks the moment\none of them is removed or replaced. Cooperate instead through:\n\n- **Durable shared state** (currency, inventory, characters, progress) → the ONE SDK client every\n module receives as `ctx.client`. Gold earned in one mode is spendable in another with no plumbing.\n- **Live signals** → `ctx.events` with typed topics `<module-id>:<event>@<major>`\n (`defineTopic(\"idle-rpg:character-upgraded@1\", shape({ … }))`), declared in `module.meta.json`.\n The emitter doesn't know who listens; a listener keeps its own copy of the topic. Topics shared by\n several of THIS game's own modules can live in `src/shared/events/` and be imported on both sides.\n- **Code several modules need** → `src/shared/`.\n- **Logic that must be shared and trusted** → it belongs on the server (SDK services, CloudCode),\n not in a client file.\n\n## src/shared/ — the game's shared code\n\nFor code that two or more of THIS game's own modules need.\n\n- **Created on demand.** Code only one module needs stays inside that module; move it to\n `src/shared/` when a second module needs it, not in advance.\n- **One-way dependency.** Modules import from `src/shared/`; `src/shared/` NEVER imports a module.\n Otherwise every module using the shared code silently drags another module in with it.\n- **No game state, no game logic.** Types, constants, the game's UI components and theme, pure\n helpers. Progress and data go through the SDK client, signals through `ctx.events`.\n- **Organised by purpose:** `src/shared/ui/`, `src/shared/types/`, `src/shared/utils/` — not one pile.\n- **Catalog modules never depend on it.** A module that ships in the catalog must install into any\n game, so it is fully self-contained. A game's own module that imports `src/shared/` is tied to this\n game — fine for your own game; when you publish such a module, its shared code is copied into it\n (the Modules dialog marks these modules \"uses shared code\").\n\n## Catalog modules you change\n\nA module installed from the catalog is source in your project — change it freely. At install the\nplatform records its file fingerprints in `idos.modules.lock.json`; once any file differs, the module\ncounts as **customized**, and updating it from the catalog is refused until the user confirms\noverwriting their changes in the Modules dialog. An agent never forces that overwrite. Write down\nnon-obvious customizations in `docs/feature-history/` so the next person knows why the module differs\nfrom the catalog.\n\n## Files\n\n- Keep files focused. When a change adds a new responsibility to a file past ~400 lines, move that\n part into its own file — as part of that change, not as a separate drive-by refactor.\n- `src/idos.title.ts` and `idos.modules.lock.json` are platform-owned: never edit them.\n\n## Documentation\n\n- **IDOS.md** — the always-loaded guide: what the game is (\"This game\"), layout, conventions,\n \"never do X\" constraints, lasting user preferences. Short and evergreen; change a line when a fact\n changes. The \"Installed modules\" block is maintained by the platform.\n- **docs/feature-history/<slug>.md** — one file per game system or feature: what was built, why,\n and the decisions and constraints the code does not show. Update the system's file when it\n changes, and give every file ONE line in `docs/feature-history/README.md`:\n `- [Title](slug.md) — one-line gist`.\n- Never append a feature write-up to IDOS.md. It is loaded on every run, so every paragraph there is\n paid for by all future work — that is exactly how instruction files bloat.\n- Feature-history entries are not loaded automatically: open the relevant one before changing that\n system.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|