@idosgames/mcp 0.1.17 → 0.1.19
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 +74 -22
- package/registry/index.json +761 -115
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/character.json +75 -0
- package/registry/modules/chat.json +71 -0
- package/registry/modules/craft.json +71 -0
- package/registry/modules/deals.json +70 -0
- package/registry/modules/events.json +77 -0
- package/registry/modules/friends.json +70 -0
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +70 -0
- package/registry/modules/invite.json +70 -0
- package/registry/modules/leaderboard.json +72 -0
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/profile.json +71 -0
- package/registry/modules/pvp.json +69 -0
- package/registry/modules/rewards.json +72 -0
- package/registry/modules/season.json +76 -0
- package/registry/modules/store.json +72 -0
- package/registry/modules/tutorials.json +68 -0
- package/registry/modules/voxelcraft.json +65 -464
- package/registry/modules/workshop.json +13 -8
- package/registry/skills/dev-test-loop.json +1 -1
- 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 +1 -1
- package/registry/skills/idosgames-ui-kit.json +6 -0
- package/registry/skills/leaderboard-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +3 -3
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/game-hud.json +0 -99
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "referral-system",
|
|
3
3
|
"description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (their own SHORT invite code, a ready-made invite link, who they're subscribed to, follower count, claimed invite rewards), activate someone else's code, and claim a staged invite reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants invite-friend / referral-code / refer-a-friend UIs, follower-milestone reward screens, or otherwise touches client.referral, ReferralService, ReferralDefinitions, UserReferralState, or referral codes — even if they don't name the module explicitly. Also use it for \"share my invite link\", \"enter a friend's code\" and invite-code screens.",
|
|
4
|
-
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (their own SHORT invite code, a ready-made invite link, who they're\n subscribed to, follower count, claimed invite rewards), activate someone\n else's code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly. Also use\n it for \"share my invite link\", \"enter a friend's code\" and invite-code\n screens.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player gets\na **short human code** (`WDJBMJHT`) and a ready-made **invite link**, a new\nplayer **activates** someone else's code once, the referrer's `FollowersCount`\ngoes up, and the referrer can later **claim** staged rewards as that count\ncrosses configured thresholds.\n\n> ⚠ **A code is NOT a `UserID`.** Older docs and older UI said it was, and it\n> used to be true. It is not any more: the code is 8 characters from a\n> confusable-free alphabet, minted per player per title, and a `UserID` is not\n> accepted in the code field at all. Never render a `UserID` as \"your code\".\n\nMost bindings never touch this module's UI: the SDK captures `?ref=`, an\ninvite link, a Telegram `start_param` and ad tags at launch and ships them with\nthe login, so the server binds the player before any screen is shown. Manual\nentry is the fallback for people who arrived without a link. That capture side\n— and the attribution it feeds — is the `acquisition-attribution` skill; this\none is only about the referral loop itself. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — this player's own `Code`, who\n they activated (`SubscribedToUserID`), whether their activation reward was\n granted, whether one is still `PendingActivationReward`, their own\n `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`, which also returns\n `InviteUrl` alongside it (on the response, not inside `Referral`).\n\n⚠ **`getUserState()` is not a pure read, and two things depend on that.** The\nserver mints the player's `Code` LAZILY on that call — most players never\ninvite anyone, so nobody gets a code until they open the screen — and it\nsettles `PendingActivationReward`, paying the activation reward to a player who\nwas bound at login rather than by typing a code. So: call it when you open the\ninvite screen, and do not serve that screen from a stale cache.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a **deterministic** `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nBecause the key is deterministic, a **retry of the same operation is a replay,\nnot a second grant**: `ResourceService` recognises the reason, returns the\nstored result and grants nothing again. Still disable the button while a call\nis in flight — that's about the player seeing what happened, not about\ndouble-paying.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to `data.ReferrerUserID` — the referrer's **id**, which\n the response carries separately from the code that was typed — clears\n `PendingActivationReward`, and applies `data.Resources` (the\n `ActivationReward`, only present when `IsFirstActivation` is true) to cached\n balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **The code must be well-formed before it is even looked up.** Eight\n characters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, no `0/O`, no `1/I/L`),\n separators ignored. Anything else — including a `UserID` — fails with\n `\"Referral code is invalid\"` without a database round-trip.\n- **An unknown but well-formed code** fails the same way.\n- **Self-referral is rejected** with `\"Cannot activate your own referral code\"`\n — the server resolves the code to a player first and compares ids, so a\n player pasting their own link gets this rather than a generic error.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount` is decremented, the new one incremented, and\n `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n// ⚠ There is no FollowerIDs — only the count. Activating a code also makes the\n// two players friends, so the identities are `client.social.getFriendsList()`.\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Show the player's own code and invite link\n\n```ts\nconst res = await client.referral.getUserState();\nif (!res.ok) return showLoadFailed(res.error); // NOT the same as \"no code yet\"\n\nconst code = res.data.Referral?.Code; // e.g. \"WDJBMJHT\"\nconst link = res.data.InviteUrl; // e.g. \"https://idosgames.com/go/MYTITLE?ref=WDJBMJHT\"\n\n// Group it for reading aloud; the server strips separators on the way back in.\nconst pretty =\n code?.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n```\n\nTake the link **as given**. Do not assemble one from a template: the server\nbuilds it precisely so a client cannot turn it into an open redirect, and so\nevery client words it identically. No `Code` means the title has no referral\nconfig at all — hide the sharing UI rather than showing an empty box.\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(pastedByPlayer);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID (the referrer's id) + the granted ActivationReward.\n```\n\nPass through **whatever the player pasted**: the SDK accepts a bare code, a\ncode with separators, and the whole invite link, in any case. People forward\nthe link they were sent far more often than they retype eight characters, and\nhandling that in each title's UI separately is how the two drift apart.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\n`SpendRewards` describes the _rules_ (rate, source/target currency, per\nfeature) and the backend applies them automatically: after a follower spends\nin Store, Marketplace, Lootbox, Craft, Item, Character, TimedBoost, Premium,\nCurrency or Reward, the referrer is credited.\n\nThere is still nothing to call from the referrer's side, and no `referral:*`\nevent fires for them: the payout lands in **another player's** document, in a\nrequest that player made. Read `defs?.SpendRewards` to show \"earn N% back when\nyour friends spend\", and show the balance itself from the referrer's own\nstate on their next request.\n\n## Gotchas\n\n- **`SpendRewards` is wired and pays.** (This entry used to claim the\n opposite; it was wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called\n from 12 modules after a successful deduction. What is still true: the\n referrer gets no event and no cache update from it, because the payout\n happens inside someone else's request. Their balance shows up on their next\n own request.\n- **The rate field is `Rate`, a FRACTION.** `0.05` means 5%. It is not\n `Percent`, and it is not `5`. A rule with no `Rate` is skipped silently — it\n neither pays nor errors.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **The code is a short human code (`WDJB-MJHT`), not a `UserID`.** The player\n reads it off a screen or gets it inside an invite link;\n `client.referral.activateReferralCode` accepts either the bare code or the\n whole link pasted. An internal identifier is not accepted at all.\n- **A code belongs to a TITLE, not to the platform.** It is stored under\n `{titleID}:{code}` and is unique only inside that title, which is why the\n invite link always carries the title (`/go/{titleID}?ref=...`) and why a\n link shaped like `/i/{code}` cannot exist.\n- **Most players never type it.** `AcquisitionCapture` (in `@idosgames/core`,\n wired automatically) reads `?ref=`, `idos_click`, Telegram `start_param` and\n ad tags at launch, keeps them across the login screen, and ships them with\n whichever sign-in the player uses. Manual entry is the fallback for people\n who arrived without a link. You do not call it and must not duplicate it.\n- **A player bound at login has an unpaid reward until the screen is opened.**\n That is what `PendingActivationReward` means: the login path has no per-user\n lock, so the server flags the debt and settles it on the next\n `getUserState()`. If your game never opens an invite screen, that reward is\n never paid — put the call somewhere the player reaches.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard; the rejection (`\"Cannot activate your own referral code\"`) only comes\n back after the round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **A double-clicked \"Activate\"/\"Claim\" does NOT pay twice.** The idempotency\n key is deterministic, so the repeat is a replay and grants nothing again.\n (This entry used to claim the opposite.) Disable the control while a call is\n in flight anyway — so the player can tell what happened.\n- **Input is normalised for you** — `activateReferralCode` pulls the code out\n of a pasted link, strips separators, trims and uppercases before sending. Do\n not pre-clean it yourself; a second implementation of the same rule is how\n \"this code is invalid\" starts happening to valid codes.\n- **`SubscribedToUserID` holds an ID, `Code` holds a code.** They are different\n fields with different shapes, and the activation response carries both\n (`ReferrerUserID` and `ReferralCode`). Rendering one where the other belongs\n is the single easiest mistake here.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
4
|
+
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (their own SHORT invite code, a ready-made invite link, who they're\n subscribed to, follower count, claimed invite rewards), activate someone\n else's code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly. Also use\n it for \"share my invite link\", \"enter a friend's code\" and invite-code\n screens.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player gets\na **short human code** (`WDJBMJHT`) and a ready-made **invite link**, a new\nplayer **activates** someone else's code once, the referrer's `FollowersCount`\ngoes up, and the referrer can later **claim** staged rewards as that count\ncrosses configured thresholds.\n\n> ⚠ **A code is NOT a `UserID`.** Older docs and older UI said it was, and it\n> used to be true. It is not any more: the code is 8 characters from a\n> confusable-free alphabet, minted per player per title, and a `UserID` is not\n> accepted in the code field at all. Never render a `UserID` as \"your code\".\n\nMost bindings never touch this module's UI: the SDK captures `?ref=`, an\ninvite link, a Telegram `start_param` and ad tags at launch and ships them with\nthe login, so the server binds the player before any screen is shown. Manual\nentry is the fallback for people who arrived without a link. That capture side\n— and the attribution it feeds — is the `acquisition-attribution` skill; this\none is only about the referral loop itself. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — this player's own `Code`, who\n they activated (`SubscribedToUserID`), whether their activation reward was\n granted, whether one is still `PendingActivationReward`, their own\n `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`, which also returns\n `InviteUrl` alongside it (on the response, not inside `Referral`).\n\n⚠ **`getUserState()` is not a pure read, and two things depend on that.** The\nserver mints the player's `Code` LAZILY on that call — most players never\ninvite anyone, so nobody gets a code until they open the screen — and it\nsettles `PendingActivationReward`, paying the activation reward to a player who\nwas bound at login rather than by typing a code. So: call it when you open the\ninvite screen, and do not serve that screen from a stale cache.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a **deterministic** `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nBecause the key is deterministic, a **retry of the same operation is a replay,\nnot a second grant**: `ResourceService` recognises the reason, returns the\nstored result and grants nothing again. Still disable the button while a call\nis in flight — that's about the player seeing what happened, not about\ndouble-paying.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to `data.ReferrerUserID` — the referrer's **id**, which\n the response carries separately from the code that was typed — clears\n `PendingActivationReward`, and applies `data.Resources` (the\n `ActivationReward`, only present when `IsFirstActivation` is true) to cached\n balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **The code must be well-formed before it is even looked up.** Eight\n characters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, no `0/O`, no `1/I/L`),\n separators ignored. Anything else — including a `UserID` — fails with\n `\"Referral code is invalid\"` without a database round-trip.\n- **An unknown but well-formed code** fails the same way.\n- **Self-referral is rejected** with `\"Cannot activate your own referral code\"`\n — the server resolves the code to a player first and compares ids, so a\n player pasting their own link gets this rather than a generic error.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount` is decremented, the new one incremented, and\n `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n// ⚠ There is no FollowerIDs — only the count. Activating a code also makes the\n// two players friends, so the identities are `client.social.getFriendsList()`.\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Show the player's own code and invite link\n\n```ts\nconst res = await client.referral.getUserState();\nif (!res.ok) return showLoadFailed(res.error); // NOT the same as \"no code yet\"\n\nconst code = res.data.Referral?.Code; // e.g. \"WDJBMJHT\"\nconst link = res.data.InviteUrl; // e.g. \"https://idosgames.com/go/MYTITLE/?ref=WDJBMJHT\"\n// Web-only title (no Google Play / App Store / Telegram link): \".../app/MYTITLE/?ref=WDJBMJHT\"\n\n// Group it for reading aloud; the server strips separators on the way back in.\nconst pretty =\n code?.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n```\n\nTake the link **as given**. Do not assemble one from a template: the server\nbuilds it precisely so a client cannot turn it into an open redirect, and so\nevery client words it identically. No `Code` means the title has no referral\nconfig at all — hide the sharing UI rather than showing an empty box.\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(pastedByPlayer);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID (the referrer's id) + the granted ActivationReward.\n```\n\nPass through **whatever the player pasted**: the SDK accepts a bare code, a\ncode with separators, and the whole invite link, in any case. People forward\nthe link they were sent far more often than they retype eight characters, and\nhandling that in each title's UI separately is how the two drift apart.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\n`SpendRewards` describes the _rules_ (rate, source/target currency, per\nfeature) and the backend applies them automatically: after a follower spends\nin Store, Marketplace, Lootbox, Craft, Item, Character, TimedBoost, Premium,\nCurrency or Reward, the referrer is credited.\n\nThere is still nothing to call from the referrer's side, and no `referral:*`\nevent fires for them: the payout lands in **another player's** document, in a\nrequest that player made. Read `defs?.SpendRewards` to show \"earn N% back when\nyour friends spend\", and show the balance itself from the referrer's own\nstate on their next request.\n\n## Gotchas\n\n- **`SpendRewards` is wired and pays.** (This entry used to claim the\n opposite; it was wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called\n from 12 modules after a successful deduction. What is still true: the\n referrer gets no event and no cache update from it, because the payout\n happens inside someone else's request. Their balance shows up on their next\n own request.\n- **The rate field is `Rate`, a FRACTION.** `0.05` means 5%. It is not\n `Percent`, and it is not `5`. A rule with no `Rate` is skipped silently — it\n neither pays nor errors.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **The code is a short human code (`WDJB-MJHT`), not a `UserID`.** The player\n reads it off a screen or gets it inside an invite link;\n `client.referral.activateReferralCode` accepts either the bare code or the\n whole link pasted. An internal identifier is not accepted at all.\n- **A code belongs to a TITLE, not to the platform.** It is stored under\n `{titleID}:{code}` and is unique only inside that title, which is why the\n invite link always carries the title (`/go/{titleID}?ref=...`, or\n `/app/{titleID}?ref=...` for a web-only title) and why a\n link shaped like `/i/{code}` cannot exist.\n- **Most players never type it.** `AcquisitionCapture` (in `@idosgames/core`,\n wired automatically) reads `?ref=`, `idos_click`, Telegram `start_param` and\n ad tags at launch, keeps them across the login screen, and ships them with\n whichever sign-in the player uses. Manual entry is the fallback for people\n who arrived without a link. You do not call it and must not duplicate it.\n- **A player bound at login has an unpaid reward until the screen is opened.**\n That is what `PendingActivationReward` means: the login path has no per-user\n lock, so the server flags the debt and settles it on the next\n `getUserState()`. If your game never opens an invite screen, that reward is\n never paid — put the call somewhere the player reaches.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard; the rejection (`\"Cannot activate your own referral code\"`) only comes\n back after the round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **A double-clicked \"Activate\"/\"Claim\" does NOT pay twice.** The idempotency\n key is deterministic, so the repeat is a replay and grants nothing again.\n (This entry used to claim the opposite.) Disable the control while a call is\n in flight anyway — so the player can tell what happened.\n- **Input is normalised for you** — `activateReferralCode` pulls the code out\n of a pasted link, strips separators, trims and uppercases before sending. Do\n not pre-clean it yourself; a second implementation of the same rule is how\n \"this code is invalid\" starts happening to valid codes.\n- **`SubscribedToUserID` holds an ID, `Code` holds a code.** They are different\n fields with different shapes, and the activation response carries both\n (`ReferrerUserID` and `ReferralCode`). Rendering one where the other belongs\n is the single easiest mistake here.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Lootbox\", \"Reward\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Rate?: number; // FRACTION: 0.05 = 5%. Never 0..100. Empty or 0 = the rule pays nothing.\n Basis?: string; // \"Spend\" (default) or \"Earn\" — what the share is taken from\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n MinSourceAmount?: number; // ignore operations below this. 0 = no floor\n MaxRewardPerOperation?: number; // cap the payout from ONE operation. 0 = no cap\n Limits?: LimitSpec; // anti-farm caps for the REFERRER: DailyCap, DailyWeightCap\n}\n```\n\n⚠ **`FeatureKey` is not free text.** The accepted values are listed in\n`ReferralV2.SpendFeatureKeys`; an unknown key never matches, so the rule stays\nsilent rather than erroring. `\"Gacha\"` is **not** one of them — the lootbox key\nis `\"Lootbox\"`.\n\n⚠ **The field is `Rate` and it is a fraction.** It used to be `Percent` holding\n0..100. A dashboard that wrote the old shape had it dropped at deserialisation\nand every rule it created silently paid nobody — no error anywhere. If you\nrender a percentage, convert at the edge and keep the wire value a fraction.\n\n**This is wired and it pays.** (This section used to claim the opposite; that\nwas wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called from 12 modules\nafter a successful deduction. What remains true is the client-side\nconsequence: the referrer gets **no event and no cache update**, because the\npayout happens inside a request made by _another_ player. Their balance simply\nreflects it on their next own request. So describe the rule in UI (\"earn N%\nback when friends spend\"), but do not build a claim or notification flow — the\ngrant is automatic and there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n Code?: string | null; // THIS player's own short code, e.g. \"WDJBMJHT\". Minted lazily on the first getUserState()\n PendingActivationReward?: boolean; // bound at login, reward not paid yet — settled by the next getUserState()\n SubscribedToUserID?: string | null; // the referrer's USER ID (not their code); null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code\n // ⚠ FollowerIDs was REMOVED: an unbounded list of UserIDs inside the player document that\n // nothing read. Only the count remains. Activating a code also makes the two players friends,\n // so the identities are reachable as a friends list (client.social.getFriendsList()).\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface ReferralInviteRewardState {\n RewardID?: string; // == the MilestoneID key\n IsClaimed?: boolean;\n ClaimedAt?: string | null;\n}\n```\n\n`InviteRewardStates` only contains entries that have actually been claimed —\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\nmilestone systems); a milestone id absent from the map simply means \"not yet\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\n`RequiredProgress`.\n\n### The code and the invite link\n\nA player's code is a **short generated string**, not their `UserID`: 8\ncharacters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, so no accidental words; no\n`0/O`, no `1/I/L`, so nothing is misread when dictated). It lives in\n`UserReferralState.Code`.\n\n- **Minted lazily**, on the first `getUserState()`. Most players never invite\n anyone, and giving every registration a row in the code collection would be\n a write per signup for a field nobody reads.\n- **Scoped to the TITLE**, stored as `{titleID}:{code}`. Two titles may hand\n out the same code to different people, which is why every invite link\n carries the title.\n- The ready-made link comes back **on the response**, as\n `UserReferralStateResponse.InviteUrl` — `idosgames.com/go/{titleID}?ref={code}`.\n The server builds it; a client-built link from a template would be an open\n redirect. It is absent whenever `Code` is.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\n point the curve is the **identity**, so a player who has not reached the first\n tier gets no bonus.\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\n real zero. `NaN`/`Infinity` collapses to `1.0`.\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\n the resolver: a reward multiplier never reduces a reward unless the publisher\n says so explicitly.\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\n `null`, the grant is returned unscaled.\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\n left alone — they're applied later, after scaling, inside\n `ResourceService`.\n - **Rounding**: each scaled amount goes through the platform-wide\n `ModifierService.Apply` with a `Multiply` step, which finishes with\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\nmultiplier)`, never negative, never silently truncated down.\n\nTo preview this on the client before the player claims, call\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\nthe exact same spec/progress/rounding server-side and returns\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\nown copy of this multiplier — it's title-wide, not per-module.\n\n---\n\n## Activation flow — server rules\n\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\nin order:\n\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\n the SDK side before this is even sent).\n2. **Shape checked before any lookup**: 8 characters from\n `BCDFGHJKMNPQRSTVWXZ`, separators ignored, case-insensitive. Anything else\n — a `UserID` included — is `\"Referral code is invalid\"` without touching\n the database.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The normalised code must resolve to a real player in THIS title, else\n `\"Referral code is invalid\"`. Resolving to the caller themselves is\n `\"Cannot activate your own referral code\"` — the comparison is on ids,\n after the lookup, so pasting your own link gives the specific message\n rather than a generic one.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n (`FollowerIDs` no longer exists — only the count is kept).\n - The new referrer's `FollowersCount` is atomically incremented.\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\n two as mutual friends (capped by the Social module's friend limit;\n silently skipped if either side is already at the cap).\n - `IsFirstActivation` is `true` only when the caller had **no** previous\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\n When true, `ActivationReward` is granted via\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\n set permanently — a later referrer switch will not re-grant it.\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\n guarding against a concurrent change (matches \"no previous referrer\" or\n \"still the previously-read referrer\"); if that races, the call fails\n with `\"Referral state was modified concurrently. Please retry.\"` and the\n client should just retry.\n\n## Claim flow — server rules\n\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\norder:\n\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\n2. Must exist in `config.Referral.InviteRewards`, else\n `\"Invite reward '{id}' not found in configuration\"`.\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\n `\"Not enough followers. Required: {n}, current: {m}\"`.\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\n5. The resolved grant (see above) is applied atomically with idempotency key\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\n only allows the write when there's no existing claimed state for that\n reward id (protects against a double-claim race the same way step 3/4\n protect against a stale read).\n"
|
|
8
|
+
"content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Lootbox\", \"Reward\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Rate?: number; // FRACTION: 0.05 = 5%. Never 0..100. Empty or 0 = the rule pays nothing.\n Basis?: string; // \"Spend\" (default) or \"Earn\" — what the share is taken from\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n MinSourceAmount?: number; // ignore operations below this. 0 = no floor\n MaxRewardPerOperation?: number; // cap the payout from ONE operation. 0 = no cap\n Limits?: LimitSpec; // anti-farm caps for the REFERRER: DailyCap, DailyWeightCap\n}\n```\n\n⚠ **`FeatureKey` is not free text.** The accepted values are listed in\n`ReferralV2.SpendFeatureKeys`; an unknown key never matches, so the rule stays\nsilent rather than erroring. `\"Gacha\"` is **not** one of them — the lootbox key\nis `\"Lootbox\"`.\n\n⚠ **The field is `Rate` and it is a fraction.** It used to be `Percent` holding\n0..100. A dashboard that wrote the old shape had it dropped at deserialisation\nand every rule it created silently paid nobody — no error anywhere. If you\nrender a percentage, convert at the edge and keep the wire value a fraction.\n\n**This is wired and it pays.** (This section used to claim the opposite; that\nwas wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called from 12 modules\nafter a successful deduction. What remains true is the client-side\nconsequence: the referrer gets **no event and no cache update**, because the\npayout happens inside a request made by _another_ player. Their balance simply\nreflects it on their next own request. So describe the rule in UI (\"earn N%\nback when friends spend\"), but do not build a claim or notification flow — the\ngrant is automatic and there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n Code?: string | null; // THIS player's own short code, e.g. \"WDJBMJHT\". Minted lazily on the first getUserState()\n PendingActivationReward?: boolean; // bound at login, reward not paid yet — settled by the next getUserState()\n SubscribedToUserID?: string | null; // the referrer's USER ID (not their code); null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code\n // ⚠ FollowerIDs was REMOVED: an unbounded list of UserIDs inside the player document that\n // nothing read. Only the count remains. Activating a code also makes the two players friends,\n // so the identities are reachable as a friends list (client.social.getFriendsList()).\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface ReferralInviteRewardState {\n RewardID?: string; // == the MilestoneID key\n IsClaimed?: boolean;\n ClaimedAt?: string | null;\n}\n```\n\n`InviteRewardStates` only contains entries that have actually been claimed —\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\nmilestone systems); a milestone id absent from the map simply means \"not yet\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\n`RequiredProgress`.\n\n### The code and the invite link\n\nA player's code is a **short generated string**, not their `UserID`: 8\ncharacters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, so no accidental words; no\n`0/O`, no `1/I/L`, so nothing is misread when dictated). It lives in\n`UserReferralState.Code`.\n\n- **Minted lazily**, on the first `getUserState()`. Most players never invite\n anyone, and giving every registration a row in the code collection would be\n a write per signup for a field nobody reads.\n- **Scoped to the TITLE**, stored as `{titleID}:{code}`. Two titles may hand\n out the same code to different people, which is why every invite link\n carries the title.\n- The ready-made link comes back **on the response**, as\n `UserReferralStateResponse.InviteUrl` — `idosgames.com/go/{titleID}/?ref={code}` when the\n title has a Google Play, App Store or Telegram link (`Attribution.StoreLinks`), otherwise the\n web app page `idosgames.com/app/{titleID}/?ref={code}` (no one-button \"pick a platform\" step).\n The server builds it; a client-built link from a template would be an open\n redirect. It is absent whenever `Code` is.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\n point the curve is the **identity**, so a player who has not reached the first\n tier gets no bonus.\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\n real zero. `NaN`/`Infinity` collapses to `1.0`.\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\n the resolver: a reward multiplier never reduces a reward unless the publisher\n says so explicitly.\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\n `null`, the grant is returned unscaled.\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\n left alone — they're applied later, after scaling, inside\n `ResourceService`.\n - **Rounding**: each scaled amount goes through the platform-wide\n `ModifierService.Apply` with a `Multiply` step, which finishes with\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\nmultiplier)`, never negative, never silently truncated down.\n\nTo preview this on the client before the player claims, call\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\nthe exact same spec/progress/rounding server-side and returns\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\nown copy of this multiplier — it's title-wide, not per-module.\n\n---\n\n## Activation flow — server rules\n\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\nin order:\n\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\n the SDK side before this is even sent).\n2. **Shape checked before any lookup**: 8 characters from\n `BCDFGHJKMNPQRSTVWXZ`, separators ignored, case-insensitive. Anything else\n — a `UserID` included — is `\"Referral code is invalid\"` without touching\n the database.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The normalised code must resolve to a real player in THIS title, else\n `\"Referral code is invalid\"`. Resolving to the caller themselves is\n `\"Cannot activate your own referral code\"` — the comparison is on ids,\n after the lookup, so pasting your own link gives the specific message\n rather than a generic one.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n (`FollowerIDs` no longer exists — only the count is kept).\n - The new referrer's `FollowersCount` is atomically incremented.\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\n two as mutual friends (capped by the Social module's friend limit;\n silently skipped if either side is already at the cap).\n - `IsFirstActivation` is `true` only when the caller had **no** previous\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\n When true, `ActivationReward` is granted via\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\n set permanently — a later referrer switch will not re-grant it.\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\n guarding against a concurrent change (matches \"no previous referrer\" or\n \"still the previously-read referrer\"); if that races, the call fails\n with `\"Referral state was modified concurrently. Please retry.\"` and the\n client should just retry.\n\n## Claim flow — server rules\n\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\norder:\n\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\n2. Must exist in `config.Referral.InviteRewards`, else\n `\"Invite reward '{id}' not found in configuration\"`.\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\n `\"Not enough followers. Required: {n}, current: {m}\"`.\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\n5. The resolved grant (see above) is applied atomically with idempotency key\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\n only allows the write when there's no existing claimed state for that\n reward id (protects against a double-claim race the same way step 3/4\n protect against a stale read).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "timed-event-system",
|
|
3
3
|
"description": "Build a time-boxed live-ops event system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.timedEvent (TimedEventService): load active events and their title-wide definitions, read the player's per-event-instance token progress, spend event tokens (single + batch), grant event tokens (single + batch, server/trigger-driven), and claim milestone rewards as token balances cross thresholds (single, batch, and claim-all). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants limited-time events, seasonal/live-ops event screens, event-token or event-currency progress bars, milestone/reward-track UIs, chain/rotation events, Coin-Master-style bonus windows, or otherwise touches client.timedEvent, TimedEventService, TimedEventDefinitions, ActiveEventInfo, EventTokenProgress, or MilestoneDefinition — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: timed-event-system\ndescription: >-\n Build a time-boxed live-ops event system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.timedEvent (TimedEventService):\n load active events and their title-wide definitions, read the player's\n per-event-instance token progress, spend event tokens (single + batch),\n grant event tokens (single + batch, server/trigger-driven), and claim\n milestone rewards as token balances cross thresholds (single, batch, and\n claim-all). Use this whenever the user is working in the iDosGames TS SDK\n or its game templates (board-game, idle-rpg) and wants limited-time\n events, seasonal/live-ops event screens, event-token or event-currency\n progress bars, milestone/reward-track UIs, chain/rotation events,\n Coin-Master-style bonus windows, or otherwise touches client.timedEvent,\n TimedEventService, TimedEventDefinitions, ActiveEventInfo,\n EventTokenProgress, or MilestoneDefinition — even if they don't name the\n module explicitly.\n---\n\n# Timed event system (iDosGames TS SDK)\n\nThe TimedEvent module runs live-ops \"limited time events\" (LTEs): a title\nschedules an event (a single window, or a repeating chain of phases), players\nearn an **event token** by doing things the event tracks (`TokenSources`), and\nas their token balance crosses configured thresholds they unlock and claim\n**milestone** rewards. A single event can also carry a cyclic \"bonus window\"\n(Coin-Master-style boosted-reward periods). Everything is\n**server-authoritative**: the client asks to spend/grant tokens or claim a\nmilestone, the backend validates and applies it, and the SDK mirrors the\nconfirmed result into a local cache your UI reads. You never mutate event\nstate yourself — you call a method, check the result, and render from the\ncache.\n\nThis skill is for **using** the production `TimedEventService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (event not active, threshold not reached, already claimed, claim window\nexpired) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n every event that can run: schedule (`ScheduleSpec`, `Mode` = `Scheduled` |\n `Chained`), display content, the event's token definition, `TokenSources`\n (what earns it), milestones, and any bonus window. A `Chained` event also\n carries `Events`: an ordered list of phases, each with its own duration and\n `Content`. Fetched with `getDefinitions()`.\n2. **Active events + user state** (state, per player) — which event\n **instances** are currently live or still claimable for this player\n (`getActiveEvents()`) and this player's raw per-instance token progress\n (`getUserLteState()`). Both cache separately; the milestone UI should read\n from the **active-events cache**, not the raw token cache (see Gotchas).\n\nAn event is identified by `Type` (`ScheduleMode`: `\"Scheduled\"` |\n`\"Chained\"`) + `LteID` (the `TimedEventID`). A `Scheduled` event has exactly\none instance (its window). A `Chained` event repeats as a sequence of\nphase-instances, each keyed by `(CycleIndex, ChainedEventID)` — the same\n`LteID` produces a new token bucket, a new milestone-claim state, and a new\nbonus-window timeline **every time the chain advances**. Batch refs\n(`TimedEventInstanceRef`) carry all four fields (`Type`, `LteID`,\n`CycleIndex`, `ChainedEventID`) so you can address one specific instance, e.g.\nto claim a just-ended chain phase during its grace window while the next\nphase is already active.\n\nFor the full field-by-field shape of Definitions, milestones, the\nper-instance token progress cache, the composite-key scheme, and the\nself-heal behavior on stale milestone state, 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 or to reason\nabout chain/cycle edge cases.\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 timedEvents = client.timedEvent; // the TimedEventService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\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. `\"Event not\nfound or not active.\"`, `\"Milestone already claimed.\"`, `\"Claim window has\nexpired.\"`).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------- |\n| `getActiveEvents()` | Load the event instances currently live/claimable for this player. | `GetActiveEventsResponse` (`ActiveEvents[]`) |\n| `getDefinitions()` | Load the title's full event catalog (config). | `TimedEventDefinitions` |\n| `getUserLteState()` | Load this player's raw per-instance token balances (state). | `UserTimedEventStateResponse` (`Tokens`) |\n| `spendTokens(type, lteID, spendAmount)` | Spend tokens on the current (or an addressed) event instance. | `EventTokenSpendResponse` (`Resources`) |\n| `spendTokensBatch(spends)` | Spend tokens across several event instances in one atomic call. | `SpendTokensBatchResponse` |\n| `grantTokens(type, lteID, sourceType, outcome?, amountOverride?, sourceParams?, rollMultiplier?)` | Report a `TokenSources` trigger firing; server computes/validates the earn. | `ResourceOperation` |\n| `grantTokensBatch(grants)` | Grant tokens across several event instances in one atomic call. | `GrantTokensBatchResponse` |\n| `claimMilestone(type, lteID, milestoneID)` | Claim one reached milestone's reward. | `EventMilestoneClaimResponse` (`Rewards`) |\n| `claimMilestonesBatch(milestones)` | Claim several specific milestones in one atomic call. | `ClaimMilestonesBatchResponse` |\n| `claimAllMilestones(events?)` | Claim every reached-but-unclaimed milestone (all active events if omitted). | `ClaimMilestonesBatchResponse` |\n\n`spendTokens` requires `spendAmount > 0` (validated client-side before the\ncall, mirrored by the server). `grantTokens`/`grantTokensBatch` are\n**trigger-reporting** calls, not a raw \"give me N tokens\" endpoint — see\nGotchas below before wiring these up from arbitrary UI actions.\n\n`spendTokens`/`grantTokens` resolve to the event's **currently active\ninstance** by default (`Type` + `LteID` only). To target a specific\nalready-ended chain instance (e.g. claiming during its grace window while a\nnew phase is already active), pass `CycleIndex` + `ChainedEventID` — the\nsingle-call methods don't expose these directly, but the batch ref types\n(`TimedEventInstanceRef` and its `TimedEventSpendRef`/`TimedEventGrantRef`/\n`TimedEventMilestoneRef` subtypes) do. Read `CurrentCycleIndex` +\n`CurrentChainedEventID` off the relevant `ActiveEventInfo` (from\n`getActiveEvents()`) to get the right values — don't compute them yourself.\n\nOn success, each method mirrors the confirmed change into the cache and emits\nan event — you don't apply anything by hand. Rewards/consumed resources ride\nin `data.Resources` / `data.Rewards` as a `ResourceOperation` (see the\ncurrency-system skill for the shared `ResourceConsume`/`ResourceGrant`/\n`ResourceOperation` shapes) and are already applied to cached balances, so\nread updated balances straight from the cache.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Active event instances (only present after getActiveEvents()); this is the\n// milestone-UI source of truth — its Progress.Milestone.ClaimedIDs is kept\n// in sync by every claim method, batch or not, and is self-healed on read\n// (see Gotchas / references/data-model.md).\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst evt = active.find((e) => e.TimedEventID === \"summer_sale\");\nevt?.Progress; // UserEventTokenProgress: Balance / Milestone / Daily / Meta\nevt?.CanEarn; // server's \"tokens can still be earned\" flag\nevt?.CanClaim; // server's \"still inside the claim/grace window\" flag\nevt?.NextMilestone; // next unclaimed MilestoneDefinition, ordered by RequiredProgress\nevt?.BonusWindow; // computed BonusWindowState, if the event has one\nevt?.CurrentCycleIndex; // Chained only\nevt?.CurrentChainedEventID; // Chained only — which phase is active/ended-in-grace\n\n// Raw per-instance token state (only present after getUserLteState()):\nconst tokens = client.data.user.state?.EventToken?.TimedEvent ?? {};\n\n// Definitions (cached after getDefinitions()):\nimport type { TimedEventDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `timedEvent:activeEventsLoaded` → `GetActiveEventsResponse`\n- `timedEvent:definitionsLoaded` → `TimedEventDefinitions`\n- `timedEvent:userStateLoaded` → `UserTimedEventStateResponse`\n- `timedEvent:tokensSpent` → `EventTokenSpendResponse`\n- `timedEvent:tokensSpentBatch` → `SpendTokensBatchResponse`\n- `timedEvent:tokensGranted` → `ResourceOperation`\n- `timedEvent:tokensGrantedBatch` → `GrantTokensBatchResponse`\n- `timedEvent:milestoneClaimed` → `EventMilestoneClaimResponse`\n- `timedEvent:milestonesClaimedBatch` → `ClaimMilestonesBatchResponse`\n- `timedEvent:allMilestonesClaimed` → `ClaimMilestonesBatchResponse`\n\nThe coarse `user:timedEventUpdated` (and umbrella `user:anyUpdated`) also fire\non any timed-event cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"timedEvent:milestoneClaimed\", (r) => {\n console.log(`${r.LteID} milestone ${r.MilestoneID} claimed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show active events with a progress bar and milestone track\n\n```ts\nawait client.timedEvent.getDefinitions();\nawait client.timedEvent.getActiveEvents();\n\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\n\nfor (const evt of active) {\n const def = defs?.Definitions?.[evt.TimedEventID ?? \"\"];\n const earned = evt.Progress?.Balance?.TotalEarned ?? 0; // milestone math uses TotalEarned, not Current\n const claimedIDs = evt.Progress?.Milestone?.ClaimedIDs ?? [];\n const milestones = evt.Content?.Milestones ?? def?.Content?.Milestones ?? {};\n // Render each milestone: reached = earned >= milestone.RequiredProgress,\n // claimed = claimedIDs.includes(milestoneID). evt.NextMilestone is the\n // cheapest way to highlight the very next unclaimed one.\n}\n```\n\n`ActiveEventInfo.Content` already carries the resolved display/token/\nmilestone content for the current (or current chained) instance — you\nusually don't need to fall back to `def.Content`, but it's there for\nreference. Milestone thresholds (`RequiredProgress`) are checked against\n`Balance.TotalEarned` (lifetime earned in this instance), **not**\n`Balance.Current` (the spendable balance) — spending tokens never un-reaches\na milestone already earned.\n\n### Spend tokens, then claim a milestone\n\n```ts\nconst spend = await client.timedEvent.spendTokens(\n \"Scheduled\",\n \"summer_sale\",\n 50,\n);\nif (!spend.ok) return showError(spend.error);\n// balances already debited/credited in the cache via spend.data.Resources.\n\nconst claim = await client.timedEvent.claimMilestone(\n \"Scheduled\",\n \"summer_sale\",\n \"milestone_3\",\n);\nif (!claim.ok) return showError(claim.error); // e.g. \"Milestone already claimed.\", \"Not enough earned...\"\n// claim.data.Rewards already applied; ActiveEventInfo.Progress.Milestone.ClaimedIDs updated.\n```\n\n### Claim every reached milestone in one tap (\"claim all\")\n\n```ts\n// Sweeps all active + grace-window-claimable events:\nconst res = await client.timedEvent.claimAllMilestones();\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Scheduled:summer_sale:s202607010000:milestone_2\"\n else showItemError(item.Id, item.Error); // not reached / already claimed\n}\n\n// Or scope it to specific event instances:\nawait client.timedEvent.claimAllMilestones([\n { Type: \"Scheduled\", LteID: \"summer_sale\" },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n },\n]);\n```\n\n`claimAllMilestones` and `claimMilestonesBatch` share the same response shape\n(`ClaimMilestonesBatchResponse`) and the same cache-patch logic — pick\n`claimAllMilestones` for a \"claim everything\" button and\n`claimMilestonesBatch` when the player picked specific milestones. Both also\nrespect `Content.ClaimMode` (`Instant` | `AfterEventEnd` | `FeaturedAfterEnd`)\n— a milestone whose mode isn't satisfied yet is simply not discovered/offered\nby `claimAllMilestones`, or rejected with an explicit error by the targeted\ncalls.\n\n### Batch spend across several events\n\n```ts\nconst res = await client.timedEvent.spendTokensBatch([\n { Type: \"Scheduled\", LteID: \"summer_sale\", SpendAmount: 20 },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n SpendAmount: 10,\n },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (!item.Success) showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that item applied.\nThe resource charge across the whole batch is merged into one atomic backend\noperation — see the character-system skill's batch recipe for the same\nall-or-nothing charge semantics, which apply here too. Up to 50 entries per\nbatch call (`ClaimMilestonesBatch`/`SpendTokensBatch`/`GrantTokensBatch`);\nentries past that are silently dropped before processing — chunk larger sets\nyourself.\n\n### Handle a chained event's cycle rollover\n\n```ts\nawait client.timedEvent.getActiveEvents();\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst raid = active.filter((e) => e.TimedEventID === \"raid_rotation\");\n\n// getActiveEvents() can return TWO entries for the same chained LteID at once:\n// the currently active phase (CanEarn true) and a just-ended phase still\n// inside its ClaimGraceHours window (CanEarn false, CanClaim true). Render\n// both: a \"claim before it's gone\" card for the grace one, the live progress\n// bar for the active one.\nfor (const phase of raid) {\n if (phase.CanClaim && !phase.CanEarn) {\n // Ended, in grace — claim using ITS OWN CycleIndex/ChainedEventID so you\n // don't accidentally resolve to the newly-active phase instead.\n await client.timedEvent.claimAllMilestones([\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: phase.CurrentCycleIndex,\n ChainedEventID: phase.CurrentChainedEventID,\n },\n ]);\n }\n}\n```\n\nEach chain instance (a given `(CycleIndex, ChainedEventID)` pair) has its own\ntoken bucket, so progress and milestone-claimed state reset to zero when the\nchain advances to a new phase or cycle — that's expected, not a bug. See\n[references/data-model.md](references/data-model.md) for the exact key\nformat and the grace-window math.\n\n### Reporting a gameplay trigger that earns tokens\n\n```ts\n// e.g. the player finished a board-loop tile that this event's TokenSources\n// lists as a SourceType. The server validates the trigger against the\n// event's TokenSources filters and computes the actual earned amount.\nconst res = await client.timedEvent.grantTokens(\n \"Scheduled\",\n \"summer_sale\",\n \"CustomAction\",\n /* outcome */ undefined,\n /* amountOverride */ undefined,\n { ActionName: \"daily_puzzle_solved\" },\n);\nif (!res.ok) return showError(res.error);\n```\n\nOnly a handful of `SourceType` values are callable from the client at all\n(`CustomAction`, `DailyLogin`, `ReferralInvite`) — see Gotchas. Gameplay\nsources like board tiles, quest completions, or store purchases are reported\nby the corresponding server module internally; you don't call `grantTokens`\nfor those from client code.\n\n## Gotchas\n\n- **`grantTokens`/`grantTokensBatch` are trigger reports, not a free-mint\n endpoint, and most `SourceType`s are server-only.** The backend rejects\n `BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`,\n `BoardBuild`, `BoardStageComplete`, `BoardSpecialComplete`, `QuestComplete`,\n `StorePurchase`, `MilestoneReward`, `MarketplaceSell`, and `MarketplaceBuy`\n outright with `\"Source '<type>' cannot be called from client.\"` — only\n `CustomAction`, `DailyLogin`, and `ReferralInvite` are callable from the\n client. Even for those, the server matches your `sourceType`/`outcome`/\n `sourceParams` against the event's configured `TokenSources` and computes\n the actual earned amount itself (`amountOverride` only takes effect if\n present and positive, and is still subject to caps) — never assume the\n client-requested amount is what lands in the balance.\n- **Two separate caches, one source of truth for milestones.** `getActiveEvents()`\n populates `client.data.user.getCachedActiveEvents()`; `getUserLteState()`\n populates `client.data.user.state?.EventToken?.TimedEvent`. Every claim\n method patches **both** (matched by composite key, see\n [references/data-model.md](references/data-model.md)), but the\n active-events cache is the one with `CanEarn`/`CanClaim`/`NextMilestone`/\n `BonusWindow` — build milestone/progress UI off that one, and treat the raw\n token cache as a secondary/debug view.\n- **Milestone state self-heals against `TotalEarned`, not against wall-clock\n cycle boundaries.** On every `getActiveEvents()` call, the server strips any\n `ClaimedIDs`/`UnlockedIDs` entry from a bucket whose milestone's\n `RequiredProgress` **exceeds that bucket's own `Balance.TotalEarned`** —\n i.e. an id that shouldn't be possible given the bucket's own recorded\n earnings (leftover/inconsistent state, e.g. from before per-instance\n keying). Because `TotalEarned` only ever increases, a milestone you\n legitimately claimed (earned >= threshold at claim time) can never be\n swept by this — the self-heal only removes ids that are inconsistent with\n their own bucket's balance. It is best-effort and silently\n self-corrects the DB in the background (a failed cleanup just retries on\n the next `getActiveEvents()` poll) — you don't need to handle it, but don't\n be surprised if a `NextMilestone` you saw as claimed reappears as\n claimable after a refresh if the state was stale.\n- **Chain instances are independent buckets — progress does not carry over.**\n A `Chained` event's token balance, milestone-claimed list, and bonus-window\n timeline are all keyed by `(LteID, CycleIndex, ChainedEventID)`. Moving to\n the next phase or cycle starts that instance's progress at zero; this is by\n design, not a reset bug to work around client-side.\n- **Grace windows keep an ended instance claimable, not earnable.** Once a\n `Scheduled` event's `EndUtc` or a chain phase's end passes, `CanEarn`\n becomes `false` immediately, but `CanClaim` stays `true` until\n `EndUtc + ClaimGraceHours` (`ClaimGraceHours` comes from the event's/phase's\n own config; 0 = no post-end claiming at all). `getActiveEvents()` will\n surface an ended-but-in-grace chain instance as its own entry alongside the\n newly active one — always claim it via its own `CycleIndex` +\n `ChainedEventID`, not the currently-active phase's.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Claim\" can\n attempt to claim twice (the second will simply fail with \"Milestone already\n claimed.\", but a double-clicked spend can genuinely spend twice). Disable\n the control while a call is in flight. Firing the same endpoint again\n within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the composite event-token key scheme, the milestone self-heal\nrule, grace-window math, and the bonus-window model. Read it when building\nconfig-driven UI (progress bars, milestone tracks, bonus-window countdowns)\nor when you need to understand how per-instance token progress is keyed and\nmatched.\n",
|
|
4
|
+
"content": "---\nname: timed-event-system\ndescription: >-\n Build a time-boxed live-ops event system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.timedEvent (TimedEventService):\n load active events and their title-wide definitions, read the player's\n per-event-instance token progress, spend event tokens (single + batch),\n grant event tokens (single + batch, server/trigger-driven), and claim\n milestone rewards as token balances cross thresholds (single, batch, and\n claim-all). Use this whenever the user is working in the iDosGames TS SDK\n or its game templates (board-game, idle-rpg) and wants limited-time\n events, seasonal/live-ops event screens, event-token or event-currency\n progress bars, milestone/reward-track UIs, chain/rotation events,\n Coin-Master-style bonus windows, or otherwise touches client.timedEvent,\n TimedEventService, TimedEventDefinitions, ActiveEventInfo,\n EventTokenProgress, or MilestoneDefinition — even if they don't name the\n module explicitly.\n---\n\n# Timed event system (iDosGames TS SDK)\n\nThe TimedEvent module runs live-ops \"limited time events\" (LTEs): a title\nschedules an event (a single window, or a repeating chain of phases), players\nearn an **event token** by doing things the event tracks (`TokenSources`), and\nas their token balance crosses configured thresholds they unlock and claim\n**milestone** rewards. A single event can also carry a cyclic \"bonus window\"\n(Coin-Master-style boosted-reward periods). Everything is\n**server-authoritative**: the client asks to spend/grant tokens or claim a\nmilestone, the backend validates and applies it, and the SDK mirrors the\nconfirmed result into a local cache your UI reads. You never mutate event\nstate yourself — you call a method, check the result, and render from the\ncache.\n\nThis skill is for **using** the production `TimedEventService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (event not active, threshold not reached, already claimed, claim window\nexpired) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n every event that can run: schedule (`ScheduleSpec`, `Mode` = `Scheduled` |\n `Chained`), display content, the event's token definition, `TokenSources`\n (what earns it), milestones, and any bonus window. A `Chained` event also\n carries `Events`: an ordered list of phases, each with its own duration and\n `Content`. Fetched with `getDefinitions()`.\n2. **Active events + user state** (state, per player) — which event\n **instances** are currently live or still claimable for this player\n (`getActiveEvents()`) and this player's raw per-instance token progress\n (`getUserLteState()`). Both cache separately; the milestone UI should read\n from the **active-events cache**, not the raw token cache (see Gotchas).\n\nAn event is identified by `Type` (`ScheduleMode`: `\"Scheduled\"` |\n`\"Chained\"`) + `LteID` (the `TimedEventID`). A `Scheduled` event has exactly\none instance (its window). A `Chained` event repeats as a sequence of\nphase-instances, each keyed by `(CycleIndex, ChainedEventID)` — the same\n`LteID` produces a new token bucket, a new milestone-claim state, and a new\nbonus-window timeline **every time the chain advances**. Batch refs\n(`TimedEventInstanceRef`) carry all four fields (`Type`, `LteID`,\n`CycleIndex`, `ChainedEventID`) so you can address one specific instance, e.g.\nto claim a just-ended chain phase during its grace window while the next\nphase is already active.\n\nFor the full field-by-field shape of Definitions, milestones, the\nper-instance token progress cache, the composite-key scheme, and the\nself-heal behavior on stale milestone state, 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 or to reason\nabout chain/cycle edge cases.\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 timedEvents = client.timedEvent; // the TimedEventService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\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. `\"Event not\nfound or not active.\"`, `\"Milestone already claimed.\"`, `\"Claim window has\nexpired.\"`).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------- |\n| `getActiveEvents()` | Load the event instances currently live/claimable for this player. | `GetActiveEventsResponse` (`ActiveEvents[]`) |\n| `getDefinitions()` | Load the title's full event catalog (config). | `TimedEventDefinitions` |\n| `getUserLteState()` | Load this player's raw per-instance token balances (state). | `UserTimedEventStateResponse` (`Tokens`) |\n| `spendTokens(type, lteID, spendAmount)` | Spend tokens on the current (or an addressed) event instance. | `EventTokenSpendResponse` (`Resources`) |\n| `spendTokensBatch(spends)` | Spend tokens across several event instances in one atomic call. | `SpendTokensBatchResponse` |\n| `grantTokens(type, lteID, sourceType, outcome?, amountOverride?, sourceParams?, rollMultiplier?)` | Report a `TokenSources` trigger firing; server computes/validates the earn. | `ResourceOperation` |\n| `grantTokensBatch(grants)` | Grant tokens across several event instances in one atomic call. | `GrantTokensBatchResponse` |\n| `claimMilestone(type, lteID, milestoneID)` | Claim one reached milestone's reward. | `EventMilestoneClaimResponse` (`Rewards`) |\n| `claimMilestonesBatch(milestones)` | Claim several specific milestones in one atomic call. | `ClaimMilestonesBatchResponse` |\n| `claimAllMilestones(events?)` | Claim every reached-but-unclaimed milestone (all active events if omitted). | `ClaimMilestonesBatchResponse` |\n\n`spendTokens` requires `spendAmount > 0` (validated client-side before the\ncall, mirrored by the server). `grantTokens`/`grantTokensBatch` are\n**trigger-reporting** calls, not a raw \"give me N tokens\" endpoint — see\nGotchas below before wiring these up from arbitrary UI actions.\n\n`spendTokens`/`grantTokens` resolve to the event's **currently active\ninstance** by default (`Type` + `LteID` only). To target a specific\nalready-ended chain instance (e.g. claiming during its grace window while a\nnew phase is already active), pass `CycleIndex` + `ChainedEventID` — the\nsingle-call methods don't expose these directly, but the batch ref types\n(`TimedEventInstanceRef` and its `TimedEventSpendRef`/`TimedEventGrantRef`/\n`TimedEventMilestoneRef` subtypes) do. Read `CurrentCycleIndex` +\n`CurrentChainedEventID` off the relevant `ActiveEventInfo` (from\n`getActiveEvents()`) to get the right values — don't compute them yourself.\n\nOn success, each method mirrors the confirmed change into the cache and emits\nan event — you don't apply anything by hand. Rewards/consumed resources ride\nin `data.Resources` / `data.Rewards` as a `ResourceOperation` (see the\ncurrency-system skill for the shared `ResourceConsume`/`ResourceGrant`/\n`ResourceOperation` shapes) and are already applied to cached balances, so\nread updated balances straight from the cache.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Active event instances (only present after getActiveEvents()); this is the\n// milestone-UI source of truth — its Progress.Milestone.ClaimedIDs is kept\n// in sync by every claim method, batch or not, and is self-healed on read\n// (see Gotchas / references/data-model.md).\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst evt = active.find((e) => e.TimedEventID === \"summer_sale\");\nevt?.Progress; // UserEventTokenProgress: Balance / Milestone / Daily / Meta\nevt?.CanEarn; // server's \"tokens can still be earned\" flag\nevt?.CanClaim; // server's \"still inside the claim/grace window\" flag\nevt?.NextMilestone; // next unclaimed MilestoneDefinition, ordered by RequiredProgress\nevt?.BonusWindow; // computed BonusWindowState, if the event has one\nevt?.CurrentCycleIndex; // Chained only\nevt?.CurrentChainedEventID; // Chained only — which phase is active/ended-in-grace\n\n// Raw per-instance token state (only present after getUserLteState()):\nconst tokens = client.data.user.state?.EventToken?.TimedEvent ?? {};\n\n// Definitions (cached after getDefinitions()):\nimport type { TimedEventDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `timedEvent:activeEventsLoaded` → `GetActiveEventsResponse`\n- `timedEvent:definitionsLoaded` → `TimedEventDefinitions`\n- `timedEvent:userStateLoaded` → `UserTimedEventStateResponse`\n- `timedEvent:tokensSpent` → `EventTokenSpendResponse`\n- `timedEvent:tokensSpentBatch` → `SpendTokensBatchResponse`\n- `timedEvent:tokensGranted` → `ResourceOperation`\n- `timedEvent:tokensGrantedBatch` → `GrantTokensBatchResponse`\n- `timedEvent:milestoneClaimed` → `EventMilestoneClaimResponse`\n- `timedEvent:milestonesClaimedBatch` → `ClaimMilestonesBatchResponse`\n- `timedEvent:allMilestonesClaimed` → `ClaimMilestonesBatchResponse`\n\nThe coarse `user:timedEventUpdated` (and umbrella `user:anyUpdated`) also fire\non any timed-event cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"timedEvent:milestoneClaimed\", (r) => {\n console.log(`${r.LteID} milestone ${r.MilestoneID} claimed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show active events with a progress bar and milestone track\n\n```ts\nawait client.timedEvent.getDefinitions();\nawait client.timedEvent.getActiveEvents();\n\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\n\nfor (const evt of active) {\n const def = defs?.Definitions?.[evt.TimedEventID ?? \"\"];\n const earned = evt.Progress?.Balance?.TotalEarned ?? 0; // milestone math uses TotalEarned, not Current\n const claimedIDs = evt.Progress?.Milestone?.ClaimedIDs ?? [];\n const milestones = evt.Content?.Milestones ?? def?.Content?.Milestones ?? {};\n // Render each milestone: reached = earned >= milestone.RequiredProgress,\n // claimed = claimedIDs.includes(milestoneID). evt.NextMilestone is the\n // cheapest way to highlight the very next unclaimed one.\n}\n```\n\n`ActiveEventInfo.Content` already carries the resolved display/token/\nmilestone content for the current (or current chained) instance — you\nusually don't need to fall back to `def.Content`, but it's there for\nreference. Milestone thresholds (`RequiredProgress`) are checked against\n`Balance.TotalEarned` (lifetime earned in this instance), **not**\n`Balance.Current` (the spendable balance) — spending tokens never un-reaches\na milestone already earned.\n\n### Spend tokens, then claim a milestone\n\n```ts\nconst spend = await client.timedEvent.spendTokens(\n \"Scheduled\",\n \"summer_sale\",\n 50,\n);\nif (!spend.ok) return showError(spend.error);\n// balances already debited/credited in the cache via spend.data.Resources.\n\nconst claim = await client.timedEvent.claimMilestone(\n \"Scheduled\",\n \"summer_sale\",\n \"milestone_3\",\n);\nif (!claim.ok) return showError(claim.error); // e.g. \"Milestone already claimed.\", \"Not enough earned...\"\n// claim.data.Rewards already applied; ActiveEventInfo.Progress.Milestone.ClaimedIDs updated.\n```\n\n### Claim every reached milestone in one tap (\"claim all\")\n\n```ts\n// Sweeps all active + grace-window-claimable events:\nconst res = await client.timedEvent.claimAllMilestones();\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Scheduled:summer_sale:s202607010000:milestone_2\"\n else showItemError(item.Id, item.Error); // not reached / already claimed\n}\n\n// Or scope it to specific event instances:\nawait client.timedEvent.claimAllMilestones([\n { Type: \"Scheduled\", LteID: \"summer_sale\" },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n },\n]);\n```\n\n`claimAllMilestones` and `claimMilestonesBatch` share the same response shape\n(`ClaimMilestonesBatchResponse`) and the same cache-patch logic — pick\n`claimAllMilestones` for a \"claim everything\" button and\n`claimMilestonesBatch` when the player picked specific milestones. Both also\nrespect `Content.ClaimMode` (`Instant` | `AfterEventEnd` | `FeaturedAfterEnd`)\n— a milestone whose mode isn't satisfied yet is simply not discovered/offered\nby `claimAllMilestones`, or rejected with an explicit error by the targeted\ncalls.\n\n### Batch spend across several events\n\n```ts\nconst res = await client.timedEvent.spendTokensBatch([\n { Type: \"Scheduled\", LteID: \"summer_sale\", SpendAmount: 20 },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n SpendAmount: 10,\n },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (!item.Success) showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that item applied.\nThe resource charge across the whole batch is merged into one atomic backend\noperation — see the character-system skill's batch recipe for the same\nall-or-nothing charge semantics, which apply here too. Up to 50 entries per\nbatch call (`ClaimMilestonesBatch`/`SpendTokensBatch`/`GrantTokensBatch`);\nentries past that are silently dropped before processing — chunk larger sets\nyourself.\n\n### Handle a chained event's cycle rollover\n\n```ts\nawait client.timedEvent.getActiveEvents();\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst raid = active.filter((e) => e.TimedEventID === \"raid_rotation\");\n\n// getActiveEvents() can return TWO entries for the same chained LteID at once:\n// the currently active phase (CanEarn true) and a just-ended phase still\n// inside its ClaimGraceHours window (CanEarn false, CanClaim true). Render\n// both: a \"claim before it's gone\" card for the grace one, the live progress\n// bar for the active one.\nfor (const phase of raid) {\n if (phase.CanClaim && !phase.CanEarn) {\n // Ended, in grace — claim using ITS OWN CycleIndex/ChainedEventID so you\n // don't accidentally resolve to the newly-active phase instead.\n await client.timedEvent.claimAllMilestones([\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: phase.CurrentCycleIndex,\n ChainedEventID: phase.CurrentChainedEventID,\n },\n ]);\n }\n}\n```\n\nEach chain instance (a given `(CycleIndex, ChainedEventID)` pair) has its own\ntoken bucket, so progress and milestone-claimed state reset to zero when the\nchain advances to a new phase or cycle — that's expected, not a bug. See\n[references/data-model.md](references/data-model.md) for the exact key\nformat and the grace-window math.\n\n### Reporting a gameplay trigger that earns tokens\n\n```ts\n// e.g. the player finished a board-loop tile that this event's TokenSources\n// lists as a SourceType. The server validates the trigger against the\n// event's TokenSources filters and computes the actual earned amount.\nconst res = await client.timedEvent.grantTokens(\n \"Scheduled\",\n \"summer_sale\",\n \"CustomAction\",\n /* outcome */ undefined,\n /* amountOverride */ undefined,\n { ActionName: \"daily_puzzle_solved\" },\n);\nif (!res.ok) return showError(res.error);\n```\n\nOnly TWO `SourceType` values are callable from the client at all: `CustomAction`\nand `DailyLogin` — see Gotchas. Everything else is credited by the server itself\nwhen the action really happens: board tiles, store purchases, quest claims,\nmarketplace deals, invites (`ReferralInvite`), currency spent/earned, real-money\npurchases (`IapPurchase`), lootbox opens, crypto deposit/withdraw, leaderboard\nrank rewards. Configure the event's `TokenSources` and do nothing in client code.\n\n## Gotchas\n\n- **`grantTokens`/`grantTokensBatch` are trigger reports, not a free-mint\n endpoint, and most `SourceType`s are server-only.** The backend rejects\n `BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`,\n `BoardBuild`, `BoardStageComplete`, `BoardSpecialComplete`, `QuestComplete`,\n `StorePurchase`, `MilestoneReward`, `MarketplaceSell`, `MarketplaceBuy`,\n `ReferralInvite`, `CurrencySpent`, `CurrencyEarned`, `CryptoSpent`,\n `CryptoEarned`, `IapPurchase`, `LootboxOpened`, `CryptoDeposit`,\n `CryptoWithdraw`, and `LeaderboardRankReward` outright with\n `\"Source '<type>' cannot be called from client.\"` — only `CustomAction` and\n `DailyLogin` are callable from the client (a whitelist since 2026-09-28; the\n server credits the rest after the request). Even for those, the server matches your `sourceType`/`outcome`/\n `sourceParams` against the event's configured `TokenSources` and computes\n the actual earned amount itself (`amountOverride` only takes effect if\n present and positive, and is still subject to caps) — never assume the\n client-requested amount is what lands in the balance.\n- **Two separate caches, one source of truth for milestones.** `getActiveEvents()`\n populates `client.data.user.getCachedActiveEvents()`; `getUserLteState()`\n populates `client.data.user.state?.EventToken?.TimedEvent`. Every claim\n method patches **both** (matched by composite key, see\n [references/data-model.md](references/data-model.md)), but the\n active-events cache is the one with `CanEarn`/`CanClaim`/`NextMilestone`/\n `BonusWindow` — build milestone/progress UI off that one, and treat the raw\n token cache as a secondary/debug view.\n- **Milestone state self-heals against `TotalEarned`, not against wall-clock\n cycle boundaries.** On every `getActiveEvents()` call, the server strips any\n `ClaimedIDs`/`UnlockedIDs` entry from a bucket whose milestone's\n `RequiredProgress` **exceeds that bucket's own `Balance.TotalEarned`** —\n i.e. an id that shouldn't be possible given the bucket's own recorded\n earnings (leftover/inconsistent state, e.g. from before per-instance\n keying). Because `TotalEarned` only ever increases, a milestone you\n legitimately claimed (earned >= threshold at claim time) can never be\n swept by this — the self-heal only removes ids that are inconsistent with\n their own bucket's balance. It is best-effort and silently\n self-corrects the DB in the background (a failed cleanup just retries on\n the next `getActiveEvents()` poll) — you don't need to handle it, but don't\n be surprised if a `NextMilestone` you saw as claimed reappears as\n claimable after a refresh if the state was stale.\n- **Chain instances are independent buckets — progress does not carry over.**\n A `Chained` event's token balance, milestone-claimed list, and bonus-window\n timeline are all keyed by `(LteID, CycleIndex, ChainedEventID)`. Moving to\n the next phase or cycle starts that instance's progress at zero; this is by\n design, not a reset bug to work around client-side.\n- **Grace windows keep an ended instance claimable, not earnable.** Once a\n `Scheduled` event's `EndUtc` or a chain phase's end passes, `CanEarn`\n becomes `false` immediately, but `CanClaim` stays `true` until\n `EndUtc + ClaimGraceHours` (`ClaimGraceHours` comes from the event's/phase's\n own config; 0 = no post-end claiming at all). `getActiveEvents()` will\n surface an ended-but-in-grace chain instance as its own entry alongside the\n newly active one — always claim it via its own `CycleIndex` +\n `ChainedEventID`, not the currently-active phase's.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Claim\" can\n attempt to claim twice (the second will simply fail with \"Milestone already\n claimed.\", but a double-clicked spend can genuinely spend twice). Disable\n the control while a call is in flight. Firing the same endpoint again\n within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the composite event-token key scheme, the milestone self-heal\nrule, grace-window math, and the bonus-window model. Read it when building\nconfig-driven UI (progress bars, milestone tracks, bonus-window countdowns)\nor when you need to understand how per-instance token progress is keyed and\nmatched.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workshop-system",
|
|
3
3
|
"description": "Let players share what they make — maps, levels, worlds, skins, 3D models, any file — through the Workshop of the iDosGames TypeScript SDK (@idosgames/core client.workshop, WorkshopService): configure content types for a title, publish with files and a thumbnail, set access (free, a price in in-game resources, or \"hold these resources to unlock\" — while held or once), browse the catalog with filters and publisher collections, acquire, download and open content, likes, favorites, following authors, reports, official content. Also covers plugging a game into the ready-made `workshop` module via ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever the user wants user-generated content, a level/map sharing screen, selling maps or skins between players, unlocking content for holders of an item, a creator catalog, or touches client.workshop, WorkshopService, publish, acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions, ContentTypes, ctx.content or the workshop module — even if they don't name the module.",
|
|
4
|
-
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `getMyFavorites`, `getCreatorProfile`, `getCollection`.\n- ⚠ **Likes, favorites, following an author, comments and reports are `client.social`, not\n `client.workshop`** (see the `social-system` skill): `client.social.like(\"WorkshopItem\", id)`,\n `favorite(\"WorkshopItem\", id)`, `follow(\"User\", creatorUserID)`,\n `report(\"WorkshopItem\", id, \"Broken\")`, `addComment(\"WorkshopItem\", id, text)`. The Workshop\n rules still apply to them (gate, visibility, \"can't like your own\", \"you may report what you\n already got\"). Cards keep `Stats.Likes`/`Favorites` and `Liked`/`Favorited`; the author's follower\n count and \"am I following\" come from `getCreatorProfile` or `client.social.getEntityStates(\"User\", …)`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired` (reactions emit\n `social:entityStateChanged`).\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module lists your local items, publishes them, and after `open
|
|
4
|
+
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `getMyFavorites`, `getCreatorProfile`, `getCollection`.\n- ⚠ **Likes, favorites, following an author, comments and reports are `client.social`, not\n `client.workshop`** (see the `social-system` skill): `client.social.like(\"WorkshopItem\", id)`,\n `favorite(\"WorkshopItem\", id)`, `follow(\"User\", creatorUserID)`,\n `report(\"WorkshopItem\", id, \"Broken\")`, `addComment(\"WorkshopItem\", id, text)`. The Workshop\n rules still apply to them (gate, visibility, \"can't like your own\", \"you may report what you\n already got\"). Cards keep `Stats.Likes`/`Favorites` and `Liked`/`Favorited`; the author's follower\n count and \"am I following\" come from `getCreatorProfile` or `client.social.getEntityStates(\"User\", …)`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired` (reactions emit\n `social:entityStateChanged`).\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module is a tab of the lobby (installed with the base in every new project); it stays hidden until\nsome game registers a content type. It lists your local items, publishes them, and after `open`\nswitches to `modeId`. A type with no handler is still browsable and acquirable — it just can't be\npublished or opened from the game. Contract details: **idosgames-module-contract**.\n\n## Don't\n\n- Don't gate access on the client — show `getContent`'s option availability and let `acquire` decide.\n- Don't write a \"license\" of your own for `WhileHeld` content: access must follow the player's\n inventory.\n- Don't cache signed URLs or put private files in the public config.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "game-hud",
|
|
3
|
-
"meta": {
|
|
4
|
-
"name": "Game HUD",
|
|
5
|
-
"type": "app",
|
|
6
|
-
"engine": "dom"
|
|
7
|
-
},
|
|
8
|
-
"sharedUi": {
|
|
9
|
-
"provides": [
|
|
10
|
-
"currency-bar",
|
|
11
|
-
"wallet",
|
|
12
|
-
"status",
|
|
13
|
-
"account"
|
|
14
|
-
]
|
|
15
|
-
},
|
|
16
|
-
"catalog": {
|
|
17
|
-
"type": "feature",
|
|
18
|
-
"summary": "Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.",
|
|
19
|
-
"description": "The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.",
|
|
20
|
-
"provides": [
|
|
21
|
-
"one shared currency balance bar for every mode",
|
|
22
|
-
"one crypto wallet button (deposit / withdraw) for the whole game",
|
|
23
|
-
"shared status line / toast messages across modes",
|
|
24
|
-
"player ID with copy and a log-out button",
|
|
25
|
-
"consistent HUD when mixing several game templates"
|
|
26
|
-
],
|
|
27
|
-
"tags": [
|
|
28
|
-
"hud",
|
|
29
|
-
"ui",
|
|
30
|
-
"shared",
|
|
31
|
-
"wallet",
|
|
32
|
-
"currency",
|
|
33
|
-
"status",
|
|
34
|
-
"chrome"
|
|
35
|
-
],
|
|
36
|
-
"author": {
|
|
37
|
-
"name": "iDos Games",
|
|
38
|
-
"url": "https://idosgames.com"
|
|
39
|
-
},
|
|
40
|
-
"version": "0.1.0",
|
|
41
|
-
"events": {
|
|
42
|
-
"listens": [
|
|
43
|
-
{
|
|
44
|
-
"topic": "host:mode-changed@1",
|
|
45
|
-
"why": "clears the previous mode's status message when the player switches modes"
|
|
46
|
-
}
|
|
47
|
-
]
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
"dependencies": {
|
|
51
|
-
"@idosgames/core": "0.18.0",
|
|
52
|
-
"@idosgames/module-sdk": "0.4.1",
|
|
53
|
-
"@idosgames/react": "0.2.11",
|
|
54
|
-
"@idosgames/wallet": "0.5.0",
|
|
55
|
-
"@tanstack/react-query": "5.101.2",
|
|
56
|
-
"react": "19.2.7",
|
|
57
|
-
"viem": "2.55.2",
|
|
58
|
-
"wagmi": "3.7.2"
|
|
59
|
-
},
|
|
60
|
-
"contentHash": "176e3d1a194c398afafe43b05df5283fa0a45acf6a95442b7222bc1ccf52c61a",
|
|
61
|
-
"files": [
|
|
62
|
-
{
|
|
63
|
-
"path": "AccountChip.tsx",
|
|
64
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The player's ID (click to copy) and \"Log out\". This HUD takes the \"account\" role, so the host does\n// not draw its own copy in the bottom-left corner. Logging out goes through the SDK: it emits\n// \"auth:loggedOut\", and the host returns to the login screen and tears the modules down.\n\nexport function AccountChip(): ReactNode {\n const client = useIDosGamesClient();\n const userID = client.auth.context?.userID ?? null;\n const [copied, setCopied] = useState(false);\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n // Clipboard API needs a secure context; the drag-select fallback still works.\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n\n // Shown truncated, but the click always copies the COMPLETE id; hovering shows it in the tooltip.\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n\n return (\n <>\n {userID && (\n <button\n type=\"button\"\n style={idStyle}\n onClick={copy}\n title={`Copy your player ID: ${userID}`}\n >\n {copied ? \"✓ Copied\" : `ID: ${shortID} ⧉`}\n </button>\n )}\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => void client.auth.logout()}\n title=\"Log out\"\n >\n 🚪 Log out\n </button>\n </>\n );\n}\n\nconst buttonStyle: CSSProperties = {\n border: \"none\",\n borderRadius: 12,\n padding: \"8px 14px\",\n fontWeight: 700,\n fontSize: 13,\n color: \"#cfe0ff\",\n background: \"rgba(4,32,90,0.72)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n};\nconst idStyle: CSSProperties = {\n ...buttonStyle,\n fontWeight: 600,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n};\n"
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
"path": "CurrencyChips.tsx",
|
|
68
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport type { CurrencyDefinitions } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\n\n// The player's virtual-currency balances, one chip each. Labels come from the title's currency\n// configuration (`Currency.VirtualCurrencies[id].DisplayName`), translated through the title's\n// localization when that name is a localization key; a currency without a definition shows its id.\n\nexport function CurrencyChips(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n const balances = state?.InventoryV2?.VirtualCurrencies ?? {};\n const definitions =\n client.data.config.getSection<CurrencyDefinitions>(\"Currency\")\n ?.VirtualCurrencies ?? {};\n\n const label = (id: string): string => {\n const name = definitions[id]?.DisplayName;\n if (!name) return id;\n // DisplayName is either plain text or a localization key — translate only when it IS a key, so\n // a title with MissingKeyMode \"ReturnMarkedKey\" does not show ⟦Gold⟧.\n return client.localization.has(name) ? client.localization.t(name) : name;\n };\n\n const entries = Object.entries(balances);\n if (entries.length === 0) return null;\n\n return (\n <>\n {entries.map(([id, currency]) => (\n <span key={id} style={chipStyle} title={id}>\n {label(id)}: {currency.Amount ?? 0}\n </span>\n ))}\n </>\n );\n}\n\nconst chipStyle: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n whiteSpace: \"nowrap\",\n pointerEvents: \"auto\",\n};\n"
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
"path": "GameHud.tsx",
|
|
72
|
-
"content": "import {\n useEffect,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { useStatus } from \"@idosgames/react\";\nimport type { ModeStore } from \"./modeStore\";\nimport { CurrencyChips } from \"./CurrencyChips\";\nimport { WalletButton } from \"./WalletButton\";\nimport { StatusToast } from \"./StatusToast\";\nimport { AccountChip } from \"./AccountChip\";\n\n// The HUD row: balances on the left, wallet + account on the right, the status toast floating under\n// the middle. It renders in the host's \"hud\" slot, which is a flex row across the top of the screen;\n// the host measures that row and moves every mode's overlay below it, so templates never slide under\n// the HUD.\n//\n// Pointer events: the row itself lets clicks through to the game (voxelcraft needs the canvas), only\n// the actual controls take them.\n\n/** Which parts this HUD draws — the shared-UI roles it won (see module.ts). */\nexport interface HudParts {\n currencies: boolean;\n wallet: boolean;\n status: boolean;\n account: boolean;\n}\n\nexport function makeGameHudPanel(\n mode: ModeStore,\n parts: HudParts,\n): ComponentType {\n return function GameHudPanel(): ReactNode {\n return <GameHud mode={mode} parts={parts} />;\n };\n}\n\nexport function GameHud({\n mode,\n parts,\n}: {\n mode: ModeStore;\n parts: HudParts;\n}): ReactNode {\n const current = useSyncExternalStore(mode.subscribe, mode.get);\n const { clear } = useStatus();\n\n // A status message belongs to the mode that wrote it: the board's \"Rolling…\" must not linger over\n // Idle RPG. (Hide parts per mode here too if a mode does not need them — `current` is its id.)\n useEffect(() => {\n clear();\n }, [current, clear]);\n\n return (\n <div style={barStyle} data-mode={current ?? undefined}>\n <div style={sideStyle}>{parts.currencies && <CurrencyChips />}</div>\n <div style={sideStyle}>\n {parts.wallet && <WalletButton />}\n {parts.account && <AccountChip />}\n </div>\n {parts.status && <StatusToast />}\n </div>\n );\n}\n\nconst barStyle: CSSProperties = {\n flex: 1,\n minWidth: 0,\n position: \"relative\",\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"flex-start\",\n gap: 12,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n pointerEvents: \"none\",\n};\nconst sideStyle: CSSProperties = {\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 8,\n minWidth: 0,\n};\n"
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
"path": "index.ts",
|
|
76
|
-
"content": "// @idosgames/mod-game-hud — the shared HUD for games made of several modules.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// after copying this into src/modules/.\n\nexport { gameHudModule } from \"./module\";\n\nexport { GameHud, makeGameHudPanel, type HudParts } from \"./GameHud\";\nexport { CurrencyChips } from \"./CurrencyChips\";\nexport { WalletButton } from \"./WalletButton\";\nexport { StatusToast } from \"./StatusToast\";\nexport { AccountChip } from \"./AccountChip\";\nexport { createModeStore, type ModeStore } from \"./modeStore\";\n"
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
"path": "modeStore.ts",
|
|
80
|
-
"content": "// The active mode, as announced by the host (`host:mode-changed@1`). A store, not React state: the\n// subscription lives in `setup()` for the whole session, the panel reads it with\n// useSyncExternalStore.\n\nexport interface ModeStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n set(mode: string): void;\n}\n\nexport function createModeStore(): ModeStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n set(mode) {\n if (mode === current) return;\n current = mode;\n for (const listener of [...listeners]) listener();\n },\n };\n}\n"
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
"path": "module.ts",
|
|
84
|
-
"content": "import { defineModule, hostTopics, type Module } from \"@idosgames/module-sdk\";\nimport { createModeStore } from \"./modeStore\";\nimport { makeGameHudPanel, type HudParts } from \"./GameHud\";\n\n// The Game HUD feature module: no scene, no route — one HUD panel that stays on screen in every mode\n// and draws the chrome every mode shares. It declares every shared-UI role, so templates mixed into\n// the same game ask `ctx.sharedUi.shouldDraw(role)`, get `false` and hide their own copies.\nexport const gameHudModule: Module = defineModule({\n id: \"game-hud\",\n meta: {\n name: \"Game HUD\",\n type: \"app\",\n engine: \"dom\",\n },\n sharedUi: { provides: [\"currency-bar\", \"wallet\", \"status\", \"account\"] },\n setup(ctx) {\n // Draw only the roles this module actually won: if another provider comes first in\n // src/modules.ts and takes, say, \"wallet\", this HUD hides its wallet button like any template.\n const parts: HudParts = {\n currencies: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n account: ctx.sharedUi.shouldDraw(\"account\"),\n };\n\n // Subscribed here, not in the panel: setup() runs before the shell mounts, so even the first\n // activation is seen. The panel reads the current mode from the store.\n const mode = createModeStore();\n ctx.events.on(hostTopics.modeChanged, ({ to }) => mode.set(to));\n\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: makeGameHudPanel(mode, parts),\n });\n\n ctx.exposeToAgent({\n state: () => ({ mode: mode.get(), draws: parts }),\n describeActions: {},\n });\n },\n});\n"
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
"path": "StatusToast.tsx",
|
|
88
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"@idosgames/react\";\n\n// The one status line for the whole game: whatever any module writes with `useStatus().setStatus`\n// lands here, because the host wraps every module in a single root StatusProvider. Floats under the\n// HUD (absolute), so a message appearing does not push the modes' layout down. Click to dismiss; the\n// HUD also clears it on every mode switch.\n\nconst colors: Record<string, string> = {\n info: \"#a9c6ff\",\n success: \"#5ad19a\",\n error: \"#ff6b6b\",\n};\n\nexport function StatusToast(): ReactNode {\n const { message, clear } = useStatus();\n if (!message) return null;\n return (\n <button\n type=\"button\"\n style={{ ...toastStyle, color: colors[message.kind] ?? \"#fff\" }}\n onClick={clear}\n title=\"Dismiss\"\n >\n {message.kind === \"error\" ? \"⚠ \" : message.kind === \"success\" ? \"✓ \" : \"\"}\n {message.text}\n </button>\n );\n}\n\nconst toastStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n maxWidth: \"min(560px, 80vw)\",\n border: \"none\",\n borderRadius: 12,\n padding: \"6px 14px\",\n fontSize: 13,\n fontWeight: 600,\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.85)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n};\n"
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
"path": "WalletButton.tsx",
|
|
92
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { LazyWalletPanel } from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The one wallet entry for the whole game (deposit/withdraw), opened as a dropdown under the button.\n// The dropdown is absolutely positioned, so opening it does not change the HUD's height — the host\n// measures that height to place every mode below it.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls Reown\n// AppKit into the startup graph and blanks the preview for web3 titles. The wallet config is memoised\n// per project id, so a wallet connected on the sign-in screen is already connected here.\n\nexport function WalletButton(): ReactNode {\n const client = useIDosGamesClient();\n const [open, setOpen] = useState(false);\n\n return (\n <div style={anchorStyle}>\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => setOpen((value) => !value)}\n >\n 💰 Wallet\n </button>\n {open && (\n <div style={dropdownStyle}>\n <LazyWalletPanel\n client={client}\n appName=\"iDosGames\"\n onClose={() => setOpen(false)}\n />\n </div>\n )}\n </div>\n );\n}\n\nconst anchorStyle: CSSProperties = {\n position: \"relative\",\n pointerEvents: \"auto\",\n};\nconst buttonStyle: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst dropdownStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n right: 0,\n zIndex: 5,\n};\n"
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
"path": "module.meta.json",
|
|
96
|
-
"content": "{\n \"id\": \"game-hud\",\n \"type\": \"feature\",\n \"summary\": \"Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.\",\n \"description\": \"The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.\",\n \"provides\": [\n \"one shared currency balance bar for every mode\",\n \"one crypto wallet button (deposit / withdraw) for the whole game\",\n \"shared status line / toast messages across modes\",\n \"player ID with copy and a log-out button\",\n \"consistent HUD when mixing several game templates\"\n ],\n \"tags\": [\"hud\", \"ui\", \"shared\", \"wallet\", \"currency\", \"status\", \"chrome\"],\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\",\n \"events\": {\n \"listens\": [\n {\n \"topic\": \"host:mode-changed@1\",\n \"why\": \"clears the previous mode's status message when the player switches modes\"\n }\n ]\n }\n}\n"
|
|
97
|
-
}
|
|
98
|
-
]
|
|
99
|
-
}
|