@idosgames/mcp 0.1.44 → 0.1.45

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.
Files changed (38) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +119 -119
  5. package/registry/modules/ai.json +5 -5
  6. package/registry/modules/board-game.json +6 -6
  7. package/registry/modules/boosts.json +5 -5
  8. package/registry/modules/character.json +9 -9
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +5 -5
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +5 -5
  13. package/registry/modules/deals.json +6 -6
  14. package/registry/modules/events.json +3 -3
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/hero-run.json +5 -5
  17. package/registry/modules/idle-rpg.json +7 -7
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/leaderboard.json +3 -3
  21. package/registry/modules/lootboxes.json +5 -5
  22. package/registry/modules/mailbox.json +3 -3
  23. package/registry/modules/marketplace.json +3 -3
  24. package/registry/modules/mini-game.json +3 -3
  25. package/registry/modules/multiplayer.json +3 -3
  26. package/registry/modules/premium.json +5 -5
  27. package/registry/modules/profile.json +3 -3
  28. package/registry/modules/pvp.json +3 -3
  29. package/registry/modules/quests.json +3 -3
  30. package/registry/modules/rewards.json +3 -3
  31. package/registry/modules/season.json +3 -3
  32. package/registry/modules/store.json +6 -6
  33. package/registry/modules/tutorials.json +3 -3
  34. package/registry/modules/video-studio.json +8 -8
  35. package/registry/modules/voxelcraft.json +5 -5
  36. package/registry/modules/workshop.json +6 -6
  37. package/registry/skills/idosgames-ui-kit.json +1 -1
  38. package/registry/skills/store-system.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-ui-kit",
3
3
  "description": "Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready system modules: change colours, animations and sounds in ONE file (src/ui.config.ts), install a ready system (shop, VIP, heroes, inventory, chests, quests, leaderboard, daily rewards, season, friends, chat, profile, workshop, AI studio …) instead of writing it, open or register a screen (ctx.features.open / register, setAvailable), split a dense screen into popups, play UI sounds, celebrate a reward, mark tutorial anchors and read sound / motion prefs. Use this whenever the user asks to change how the game's interface looks, sounds or animates, or to add a screen, menu, popup, reward animation, bottom navigation or settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.",
4
- "content": "---\nname: idosgames-ui-kit\ndescription: >-\n Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui)\n and the ready system modules: change colours, animations and sounds in ONE\n file (src/ui.config.ts), install a ready system (shop, VIP, heroes, inventory,\n chests, quests, leaderboard, daily rewards, season, friends, chat, profile,\n workshop, AI studio …) instead of writing it, open or register a screen\n (ctx.features.open / register, setAvailable), split a dense screen into\n popups, play UI sounds, celebrate a reward, mark tutorial anchors and read\n sound / motion prefs. Use this whenever the user asks to change how the\n game's interface looks, sounds or animates, or to add a screen, menu, popup,\n reward animation, bottom navigation or settings screen, or touches\n @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features,\n useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.\n---\n\n# The UI kit and the system modules\n\nThe app's interface is the base's lobby (from `@idosgames/app-shell`) plus **system modules**, all on one **UI kit**:\n\n- `@idosgames/react/ui` — the kit: components (buttons, tabs, popups, resource lists, item tiles,\n progress bars, timers), the reward popup, toasts, UI sounds (synthesized, no files), animations,\n and the player's own sound / motion prefs.\n- system modules — the platform's systems, each a whole `feature` module (its screen = a lobby\n tab, its client logic, its demo settings). The base installs six of them into every new project\n (`inBase`: profile, inventory, invite, friends, quests, mailbox); the rest are installed only when the app\n needs them (a game's `uses`, or a task that needs what they `provide`). ONE system per player-facing module of the engine\n (`API/Client/v2/<Module>`) — the owner's rule since 29.09.2026. The module id is the feature id:\n\n| Module | Engine module | Screen |\n| ------------- | -------------- | ------------------------------------------------------------- |\n| `store` | Store+Purchase | shop: stores as tabs, offers, purchase popup |\n| `premium` | Premium | VIP passes: benefits, timer, free trial, buy / extend |\n| `character` | Character | heroes: gear slots, \"Equip best\", stats, ranks, skins |\n| `inventory` | Item | items by rarity, item card (always shown, even with no items) |\n| `lootboxes` | Lootbox | chests: every way to open, ×1 / ×10, opening animation |\n| `boosts` | TimedBoost | running boosts + global happy hours, activate a boost |\n| `leaderboard` | Leaderboard | tables, my place, rank rewards, last cycle's prize |\n| `rewards` | Reward | daily login calendar, greeting popup |\n| `quests` | Quest | quests by cycle, details, claim all, points track |\n| `collections` | Collection | album of sets, packs, chests for points, card trades |\n| `craft` | Craft | recipes, contract popup |\n| `marketplace` | Marketplace | lots, auctions, selling, trades |\n| `deals` | DealOffer | special offers, auto-popup of a new deal |\n| `events` | TimedEvent | limited-time events, milestones, VIP prize |\n| `coop-events` | CoopEvent | team event: join a group, spin to build, team prize |\n| `season` | Season | season tiers, VIP prize |\n| `friends` | Social | friends, requests, suggestions, news |\n| `chat` | Chat | channels, private chats |\n| `invite` | Referral | own code and link, friend's code, rewards (always shown) |\n| `pvp` | Match | duels, strategy, fight replay |\n| `multiplayer` | Multiplayer | rooms: list, create / join by code, ready, chat, host \"Play\" |\n| `profile` | User | name, sound, language, consent, delete account |\n| `tutorials` | Tutorial | hint overlay + replay list |\n| `workshop` | Workshop | players' and the publisher's content: catalog, publish |\n| `ai` | AI | AI studio: the title's AI features for players, history |\n\nNot a system on purpose: Currency, Authentication, Blockchain (the base — balances, sign-in,\nwallet); GameLoop (the `board-game` game); CommunityMarketing (idosgames.com only); Mailbox and\nAdvertising (no client handler / no web ad network); Analytics, Experiment, Push, Localization,\nCloudCode, DataCollections, TitleCustomData, UserCustomData (services the systems and games call).\n\nHand-offs between systems go through `ctx.features.open(id, args)`: inventory → `character`\n(`{ itemInstanceID }`) and → `lootboxes` (`{ itemID }` of a chest or key); events / season →\n`premium` (`{ premiumID }`). Multiplayer: the `multiplayer` system owns the rooms; a game registers\nin `ctx.multiplayer` and takes the room in `start(room)` (skill **multiplayer-realtime**); the lobby\nshows \"With friends\" next to such a game's \"Play\".\n\n**Need one of these? Install the module — don't write the screen.** Every screen follows the\nserver's rules and ships demo title settings (`title-seed.json`). The app does not need one → remove it\nfrom `src/modules.ts`, its tab is gone. The lobby itself (tabs, balances, wallet, profile, \"Play\") is\nthe base, `@idosgames/app-shell` (configured by `src/app.config.ts`, styled by `src/ui.config.ts`).\n\n## Change the look, animations and sounds — only in `src/ui.config.ts`\n\n```ts\nimport { defineUiConfig } from \"@idosgames/react/ui\";\n\nexport default defineUiConfig({\n theme: {\n bgTop: \"#10263f\",\n panel: \"#1b3a5c\",\n gold: \"#ffcc33\",\n radius: 12,\n rarity: { Mythic: \"#ff3df5\" },\n },\n motion: { speed: 1.5, intensity: 0.5, particles: false },\n sound: {\n volume: 0.4,\n events: {\n click: \"tab\",\n countTick: false,\n reward: { url: \"https://…/win.mp3\" },\n },\n },\n});\n```\n\n- Every field is optional; what you leave out keeps its default. The host passes the file to\n `mountHost({ ui })`, and every module on the kit follows it at once.\n- ⚠ **Never restyle a catalog module by editing it.** An edited module is marked \"customized\" and\n stops getting catalog updates. Colours, radii, fonts, animation speed and sounds are all here.\n- **One theme for the WHOLE app:** sign-in (the SDK's `DefaultLoginScreen`, mounted by\n `src/main.tsx`), the lobby, every system and the host's own screens all draw from\n these tokens. A new look — even a light theme — is an edit of this file ONLY: never recolour the\n sign-in, the logo, icons or `index.html` by hand (the logo and the sign-in icons are painted with the\n theme's colours, the page background follows `bgBottom`).\n- **The default look is Layer Lab \"Vertical Casual\"** (react ≥ 0.6): bright blue-violet screens,\n blue cards, a heavy dark **ink** outline around buttons, panels and letters, glossy plates with a\n darker bottom lip, white outlined letters, the Rubik font (shipped inside the kit, EN + RU — no CDN).\n- Theme tokens: `bgTop`, `bgBottom`, `panel`, `panelDeep`, `panelEdge`, `text`, `textDim`, `gold`,\n `goldDeep`, `green`, `greenDeep`, `blue`, `blueDeep`, `red`, `redDeep`, `purple`, `purpleDeep`,\n `orange`, `orangeDeep`, `teal`, `tealDeep`, `ink` (the outline colour), `outline` (its thickness,\n px), `nav` (the bottom menu bar), `shadow`, `backdrop`, `radius`, `buttonRadius`, `font`, `rarity`\n (rarity id → plate colour; Common blue, Uncommon teal, Rare green, Epic purple, Legendary orange,\n Mythic red).\n- **Derived tokens — usually leave them out.** They are computed from the colours above: set a light\n `panel` and dark `text`, and the ink outline of the letters disappears (`textOutline`,\n `textStroke`), panels get a soft frame instead of the ink one (`panelEdge`), lines and recesses\n turn faint-dark instead of white/black (`line`, `well`, `wellSoft`, `wellShadow`, `edge`), cards keep a\n light inner edge (`panelShadow`, never a drop under them), and buttons of a pastel tone get dark text (`onGreen`, `onGold`,\n `onBlue`, `onRed`). Set one only to override the computed value.\n- A light theme is just colours:\n `theme: { bgTop: \"#f4fcf8\", bgBottom: \"#d9f0e6\", panel: \"#ffffff\", panelDeep: \"#f1faf5\",\npanelEdge: \"#bfe6d3\", text: \"#1f3b30\", textDim: \"#5f7d71\", green: \"#7ee0b5\", greenDeep: \"#4cc493\" }`.\n- Motion: `enabled`, `speed` (2 = twice as fast), `intensity` (0 = no bounce), `rewardFlight`,\n `particles`, `stagger`, `respectReducedMotion`.\n- Sound events: `click`, `tab`, `open`, `close`, `reward`, `coin`, `purchase`, `claim`, `error`,\n `notify`, `lootbox`, `levelUp`, `countTick`. Each: another event's name, `{ url }`, `{ synth: {\nnotes: [{ freq, dur, wave }] } }` or `false`.\n- The PLAYER's own sound / volume / \"fewer animations\" live on the device (the profile module draws\n them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA module registers a **feature**; whoever draws the menu shows it — the base's lobby as tabs (phone:\n4 + \"More\", tablet and desktop: more, gathered in the middle of the bar — a full-width bar at the bottom on every width;\nthe base takes the shared-UI role `\"menu\"`), or the\nhost's floating ☰ when nobody takes the role. Tab order and main tabs can be overridden in\n`src/app.config.ts` (`lobby.order`, `lobby.primary`).\n\n```ts\n// module.ts\nsetup(ctx) {\n ctx.features.register({\n id: \"forge\", label: \"Forge\", icon: \"hammer\", group: \"economy\", order: 60,\n primary: false, // true = a main tab of the lobby\n Screen: ForgeScreen, // (props: FeatureScreenProps) => ReactNode; props.args, props.close()\n available: false, // hidden until the title has something for it — see below\n fullBleed: false, // true = the screen paints edge to edge (its own background, a stage):\n // the lobby gives it the whole area, no padding, no width limit\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n // The title has forge recipes → show the tab; the reason is for the AI Coder's agent only:\n ctx.features.setAvailable(\"forge\", hasRecipes(configSection(ctx.client, \"Craft\")), \"no forge recipes\");\n}\n```\n\n- **Width.** A plain screen is a page: the lobby pads it and gives it the full width — from a 1440px\n window on, a centred 1120px column (the `UScreen` pages then get ink sides). Build it to fit any\n width from 320px, never fixed widths; keep a small block (a week calendar, a form) to its own\n `maxWidth` and centre it. A screen with its own backdrop (a hero stage, a map) registers\n `fullBleed: true` and fills the area itself (its root `flex: \"1 0 auto\"`; it gets the full height,\n under the lobby's top row too — keep its content below `var(--idos-lobby-top)`) — never negative\n margins to escape the padding. The lobby's own background — white see-through gear icons over\n the theme — moves (`screenBackground` + `className=\"idos-screen\"`): a page leaves it see-through\n rather than painting its own. The menu is a full-width bar at the bottom on every width, \"Play\"\n in its middle on a raised gold plate.\n- **Phone, tablet and PC at once.** A list never stretches one row across a wide window: put it in\n `Columns` — `kind=\"cards\"` (cards in as many columns of `min` px as fit), `kind=\"strips\"`\n (edge-to-edge rows on a phone, framed cards side by side from 700px), `kind=\"ranked\"` (an\n ordered list running DOWN one column, then the next — a leaderboard, a level road). It reacts to\n its own width (a container query), so it fits a page, a window or a side panel alike. A heading\n or a button inside it spans the row with `gridColumn: \"1 / -1\"`. A screen of two kinds of\n content (a profile, a referral code + its rewards) is two blocks in one `Columns`.\n- **Decorations never size a row.** Rays, glows and floating icons sit `position: absolute` over\n the line they belong to; only the line's content sets its height.\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. It returns `false` when the module is not installed or its\n tab is hidden (`ctx.features.get(\"store\")?.available`). From a game (the lobby is off screen) the\n host shows the screen in a window.\n- A screen that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows — from the client config\n (`configSection(client, \"Store\")`) or a read it makes anyway (a badge watcher); no extra request\n at start-up: every module's first reads arrive together and brush the per-player rate limit.\n- Groups: `economy`, `events`, `social`, `profile`. Icons are kit glyph names (`shop`, `hero`,\n `bag`, `trophy`, `gift`, `hammer`, `market`, `offer`, `flame`, `friends`, `chat`, `link`,\n `swords`, `user`, `bulb`, …) or an image URL.\n- Something that must be on screen all the time (a daily auto-popup, an overlay) is a panel:\n `ctx.registerPanel({ id, slot: \"modal\", activeOnly: false, component })`. Background work with\n NOTHING to draw - keeping a badge in step, a poll - is NOT a panel: use `ctx.effect(() => { …; return\ncleanup })` / `ctx.every(ms, run)` in `setup()` (cleaned up on logout).\n- **A badge must go out the moment the reward is taken.** Count it in a `ctx.effect` watcher from the\n CURRENT cache on every change — `client.on(\"user:anyUpdated\", update)` reading\n `client.data.user.state` — not in an effect keyed on a state object (the cache patches it in place,\n so such an effect does not re-run and the badge stays lit). A badge counted from data the screen\n re-reads (a storefront, active events) also listens to that read's event\n (`store:storefrontLoaded`…), and a store that several places reload runs one read at a time and keeps\n what it has when a read fails — see `modules/events` / `modules/deals`.\n- **Never re-read at start-up what the login already brought** — read through the central cache\n (`client.cache`, core ≥ 0.21). State containers: `client.cache.ensureState([\"Quest\"])` (no request\n while the container's `Rev` is confirmed; the server only when it must compute something — a new\n quest cycle, a hero given by default); a screen that wants changes made elsewhere passes\n `{ maxAgeMs: 60_000 }`. Definitions: `client.x.getDefinitions()` answers from the title config cache.\n Server views (storefront, active events/deals/season, unread counters, leaderboard progress) are\n cached by their service — call them freely, and take the data FROM THE ANSWER: the login usually\n brings them before a watcher subscribes, and a read the cache answers emits no `*Loaded` event. A game's own server data: `defineView` + `client.cache.read`.\n- A section the engine does not put into the client config (`TimedBoost`, `Marketplace`, `Match`,\n `AI`) is read with the service's own `getDefinitions()` — it lands in the same config section and is\n kept under the config version, so the next login does not ask again.\n- Auto-popups (`<Popup queued>`) wait while a game mode is on screen and show in the lobby.\n\n## Building a screen on the kit\n\nKeep the screen a short list; put every detail in a **popup**. Form controls: `TextInput` / `TextArea` (the kit's text field in the theme's colours - pass `style` only for width/size), `Select` (a drop-down in the same look, `<option>`s as children; react ≥ 0.5) and `Field` (a label above a control); do not hand-style an `<input>` or a `<select>`. A choice of a few options is `Tabs` (pass `style={{ flexWrap: \"wrap\" }}` in a narrow column).\n\n```tsx\nimport {\n Button,\n Popup,\n ResourceList,\n Tabs,\n useCelebrate,\n useToast,\n useUiKit,\n errorText,\n grantedBy,\n panel,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\n\nfunction ForgeScreen(): ReactNode {\n const [open, setOpen] = useState<string | null>(null);\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {/* rows… onClick={() => setOpen(id)} */}\n {open ? (\n <Popup title=\"Forge\" onClose={() => setOpen(null)} width={420}>\n …\n </Popup>\n ) : null}\n </div>\n );\n}\n```\n\n- `Popup` is a centred window on every screen — the blue body, a lighter title bar, the slate ✕\n sticking out of the top-right corner (`variant=\"sheet\"` makes it a bottom sheet); it animates in\n and out and plays open/close sounds. Close from inside with\n `usePopupClose()`. `ConfirmPopup` for yes/no. A popup that opens BY ITSELF (not on a tap) passes\n `queued` — auto-popups then show one at a time instead of stacking.\n- `useCelebrate()(lines)` shows \"You got\" and flies the icons to any element marked\n `data-resource-counter=\"{currencyID}\"` (the lobby's balances are). Build the lines\n from a response with `grantedBy(data)`; a reward with premium (VIP) bundles needs them added.\n- `useUiKit().play(\"claim\")`, `useUiKit().fx.burst(element)` — a sound and a particle burst.\n- **Notifications — `useNotify()`** (the same system as a module's `ctx.notifications`):\n `notify.toast({ level: \"success\", title: \"Saved\" })` — a toast at the top that goes by itself;\n `await notify.alert({ level: \"warning\", title, message, actions })` — a window the player must\n answer, queued one at a time. Levels `info | success | warning | error | reward` pick the colour,\n icon, sound and how long a toast stays (errors longest). `icon` (a glyph, emoji or URL), `image` (a\n thumbnail / a big picture in the window), `source` (a feature id — its name on the card, a tap opens\n it), `key` (the same key replaces instead of stacking; identical toasts merge into \"×2\"),\n `duration` (`0` — until closed), `actions`. Refusals: `notify.toast({ level: \"error\", message:\nerrorText(res.error) })`; never re-check server rules. `useToast()(text, kind)` is the old short\n form of the same thing.\n- Lines of resources: `grantLines(grant)`, `costLines(cost)`, `bundleLines(bundle)`,\n `canAfford(lines, balances)`, `<ResourceList lines={…} />`. Names and icons: `useCatalog()`.\n- A crypto balance is ONE number — the backed crypto plus its IOU (`{ID}_IOU`, the unbacked part of\n rewards; it spends like the crypto): `cryptoBalance(state, id, catalog.cryptoCurrencies)` →\n `{ backed, unbacked, total }`, `<CryptoBalanceChip currencyID=\"Main\" />` (the total, the split in a\n hover/tap popover; the lobby's top row shows it). Never draw `{ID}_IOU` as a currency of its own\n (`iouCurrencies(...)` lists them), never round a crypto amount to whole numbers (`formatCryptoAmount`).\n- Words: `makeT(EN, RU)` for your strings, `plural(n, \"лот|лота|лотов\")` for counts. A table answers\n in the CURRENT language at every call, so a module-level `t` follows the language switch.\n- Interface language: the player's choice — `setLanguage(\"en\" | \"ru\")`, `LanguageSwitch` (English /\n Русский, already in the profile screen and the base's account popup), `useLanguage()` re-renders a\n component on change, `onLanguageChange(fn)`. Saved in localStorage `idos:language`; the window\n event `idos:language` tells games without the kit (VoxelCraft `src/i18n.ts` listens and fires it\n from its pause menu). Tab labels follow only as functions: `label: () => t(\"title\")`.\n- ⚠ A root `display: grid` with a wide child (a row of tabs) grows past a phone screen — give it\n `gridTemplateColumns: \"minmax(0, 1fr)\"`.\n- **The pieces of the casual sheet** — use them instead of drawing your own:\n - buttons: `Button tone=` `green | gold | blue | red | grey | purple | orange | teal | white |\nsilver | slate` (`gold` = the big \"Play\"/\"STAGE\", `grey` = \"Cancel\", `white` = blue letters);\n `IconButton` (a square slate plate — close, menu), `InfoButton` (the round \"i\"), `BackButton`\n (the slanted \"◀\" tab at the screen's left edge), `MenuTile` (an icon over its name on a dark-blue\n plate — \"Ranking\", \"Mission\");\n - frames: `ScreenHeader` (back + title + whatever goes right), `SectionTitle tone=` (a ribbon\n banner with a swallowtail — shop sections in turn red / green / purple / gold), `Ribbon` (a\n centred ribbon with folded tails and stars), `listRow` (a strip a shade deeper than the panel —\n letters, friends), `IconTile` (a square light tile holding an icon or a face), `rarityPlate(colour)`\n / `tonePlate(tone)` (a glossy plate in the ink frame);\n - values: `ProgressBar tone=` (a dark track, a glossy fill, \"275/100\" on it), `LevelShield`,\n `LevelBar` (the shield over the bar's left end), `HexBadge`, `StepperCounter` (\"29 / 100 +\"),\n `ResourceCounter` (the icon overlapping a dark plate), `RarityTag`, `SoldOutStamp`,\n `Toggle` (ON / OFF), `SpeechBubble`, `LevelRoadRow` (one step of a level road: `done | current |\nnext | locked`, its reward in a hexagon);\n - text: `outlined` (the theme's text with its ink outline), `inked` (white letters with the ink\n outline on ANY plate). `Tabs` are the white-active segmented strip (\"Daily | Challenge\").\n- ⚠ **No colour literals in a screen** (`\"#fff\"`, `\"rgba(0,0,0,.3)\"`, `\"#5a3fd0\"`): they break the\n moment the theme changes (white text on a light panel, a black stripe under a pastel button). Use\n the tokens: text `v.text` / `v.textDim` (or `...outlined`), surfaces `panel` / `well` / `v.wellSoft`,\n lines `v.line`, the outline `v.ink` / `v.outline`, dimming `v.backdrop`. A coloured banner, \"your\n row\", a highlighted day → `...toneSurface(tone)` (any button tone): it fills with that tone AND switches the text\n inside it to the tone's own readable colour. Text on a filled button — `ON_TONE[tone]`. A blend:\n `` `color-mix(in srgb, ${v.gold} 20%, transparent)` ``.\n\n## Tutorial hints\n\nThe `tutorials` module highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs\nare `tab:{featureID}`, its profile button `lobby:profile`, its wallet `lobby:wallet`, a game's \"Play\"\nbutton `play:{modeID}`. Mark the controls of your own screen the same\nway; the publisher points tutorial steps at them in LiveOps (Tutorial → step → AnchorID).\n\n## Fast on a cheap phone\n\n- **No SVG or CSS `filter` on anything that sits in a part of the screen that repaints** (a counter, a\n timer, a progress bar next to it). Chrome runs the filter again on the CPU at the screen's density\n on every repaint: the kit's icons once had an `feMorphology` outline and 24 of them cost a phone half\n its frames. Outlines are geometry (the kit's `Icon` draws its ink as a wider stroke under the shapes).\n- **Animate `transform` / `opacity` only**, never `background-position`, `width`, `box-shadow`, `filter` or\n colours in a loop: those repaint every frame (the drifting pattern behind kit screens is a `::before` moved\n by `transform`; `.idos-glow` is a shadow drawn once whose opacity pulses — use it, don't animate a glow yourself).\n- A Phaser game draws at the screen's density (`min(2, devicePixelRatio)`) with the camera zoomed by\n it, keeps `render.autoMobileTextures: false`, and sizes its canvas to what is visible (see the\n idle-rpg module's `phaser/density.ts` and controller). See `docs/feature-history/mobile-game-frame-perf.md`.\n\n## Size on a phone, and no shadows\n\n- **The kit's look is the ink outline only: no shadows.** No outer `box-shadow` / `text-shadow` drop\n (`0 3px 0 …`) and no `drop-shadow(0 Npx …)` filter under buttons, cards, icons or letters. Allowed:\n `inset` lips and highlights, a ring `0 0 0 Npx` (an outline), a glow `0 0 Npx`. Letters are outlined by\n `textStroke` alone; `theme.shadow` is transparent.\n- **A screen surface carries `className=\"idos-fit\"`** and is drawn with CSS `zoom` = `--idos-ui-zoom` (1 on a\n tablet or PC, 0.75…1 on a phone by its size against 430×860, `fit.ts`). The kit's popups, windows and toasts,\n the lobby, the frame around a game and the games' DOM panels already have it. A new full-screen DOM panel\n of a game puts it on its own root (or on the part over the canvas). ⚠ Never on a canvas host: a canvas\n inside a surface sits in `className=\"idos-fit-off\"`.\n- Inside a surface, `getBoundingClientRect()` is in screen pixels, `clientWidth` / `scrollTop` / `style.left`\n in the surface's own: convert with `fitScale(el)`. A canvas counts its pixels as\n `clientWidth × el.currentCSSZoom × dpr`. See `docs/feature-history/ui-kit-fit-no-shadows.md`.\n",
4
+ "content": "---\nname: idosgames-ui-kit\ndescription: >-\n Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui)\n and the ready system modules: change colours, animations and sounds in ONE\n file (src/ui.config.ts), install a ready system (shop, VIP, heroes, inventory,\n chests, quests, leaderboard, daily rewards, season, friends, chat, profile,\n workshop, AI studio …) instead of writing it, open or register a screen\n (ctx.features.open / register, setAvailable), split a dense screen into\n popups, play UI sounds, celebrate a reward, mark tutorial anchors and read\n sound / motion prefs. Use this whenever the user asks to change how the\n game's interface looks, sounds or animates, or to add a screen, menu, popup,\n reward animation, bottom navigation or settings screen, or touches\n @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features,\n useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.\n---\n\n# The UI kit and the system modules\n\nThe app's interface is the base's lobby (from `@idosgames/app-shell`) plus **system modules**, all on one **UI kit**:\n\n- `@idosgames/react/ui` — the kit: components (buttons, tabs, popups, resource lists, item tiles,\n progress bars, timers), the reward popup, toasts, UI sounds (synthesized, no files), animations,\n and the player's own sound / motion prefs.\n- system modules — the platform's systems, each a whole `feature` module (its screen = a lobby\n tab, its client logic, its demo settings). The base installs six of them into every new project\n (`inBase`: profile, inventory, invite, friends, quests, mailbox); the rest are installed only when the app\n needs them (a game's `uses`, or a task that needs what they `provide`). ONE system per player-facing module of the engine\n (`API/Client/v2/<Module>`) — the owner's rule since 29.09.2026. The module id is the feature id:\n\n| Module | Engine module | Screen |\n| ------------- | -------------- | ------------------------------------------------------------- |\n| `store` | Store+Purchase | shop: stores as tabs, offers, purchase popup |\n| `premium` | Premium | VIP passes: benefits, timer, free trial, buy / extend |\n| `character` | Character | heroes: gear slots, \"Equip best\", stats, ranks, skins |\n| `inventory` | Item | items by rarity, item card (always shown, even with no items) |\n| `lootboxes` | Lootbox | chests: every way to open, ×1 / ×10, opening animation |\n| `boosts` | TimedBoost | running boosts + global happy hours, activate a boost |\n| `leaderboard` | Leaderboard | tables, my place, rank rewards, last cycle's prize |\n| `rewards` | Reward | daily login calendar, greeting popup |\n| `quests` | Quest | quests by cycle, details, claim all, points track |\n| `collections` | Collection | album of sets, packs, chests for points, card trades |\n| `craft` | Craft | recipes, contract popup |\n| `marketplace` | Marketplace | lots, auctions, selling, trades |\n| `deals` | DealOffer | special offers, auto-popup of a new deal |\n| `events` | TimedEvent | limited-time events, milestones, VIP prize |\n| `coop-events` | CoopEvent | team event: join a group, spin to build, team prize |\n| `season` | Season | season tiers, VIP prize |\n| `friends` | Social | friends, requests, suggestions, news |\n| `chat` | Chat | channels, private chats |\n| `invite` | Referral | own code and link, friend's code, rewards (always shown) |\n| `pvp` | Match | duels, strategy, fight replay |\n| `multiplayer` | Multiplayer | rooms: list, create / join by code, ready, chat, host \"Play\" |\n| `profile` | User | name, sound, language, consent, delete account |\n| `tutorials` | Tutorial | hint overlay + replay list |\n| `workshop` | Workshop | players' and the publisher's content: catalog, publish |\n| `ai` | AI | AI studio: the title's AI features for players, history |\n\nNot a system on purpose: Currency, Authentication, Blockchain (the base — balances, sign-in,\nwallet); GameLoop (the `board-game` game); CommunityMarketing (idosgames.com only); Mailbox and\nAdvertising (no client handler / no web ad network); Analytics, Experiment, Push, Localization,\nCloudCode, DataCollections, TitleCustomData, UserCustomData (services the systems and games call).\n\nHand-offs between systems go through `ctx.features.open(id, args)`: inventory → `character`\n(`{ itemInstanceID }`) and → `lootboxes` (`{ itemID }` of a chest or key); events / season →\n`premium` (`{ premiumID }`). Multiplayer: the `multiplayer` system owns the rooms; a game registers\nin `ctx.multiplayer` and takes the room in `start(room)` (skill **multiplayer-realtime**); the lobby\nshows \"With friends\" next to such a game's \"Play\".\n\n**Need one of these? Install the module — don't write the screen.** Every screen follows the\nserver's rules and ships demo title settings (`title-seed.json`). The app does not need one → remove it\nfrom `src/modules.ts`, its tab is gone. The lobby itself (tabs, balances, wallet, profile, \"Play\") is\nthe base, `@idosgames/app-shell` (configured by `src/app.config.ts`, styled by `src/ui.config.ts`).\n\n## Change the look, animations and sounds — only in `src/ui.config.ts`\n\n```ts\nimport { defineUiConfig } from \"@idosgames/react/ui\";\n\nexport default defineUiConfig({\n theme: {\n bgTop: \"#10263f\",\n panel: \"#1b3a5c\",\n gold: \"#ffcc33\",\n radius: 12,\n rarity: { Mythic: \"#ff3df5\" },\n },\n motion: { speed: 1.5, intensity: 0.5, particles: false },\n sound: {\n volume: 0.4,\n events: {\n click: \"tab\",\n countTick: false,\n reward: { url: \"https://…/win.mp3\" },\n },\n },\n});\n```\n\n- Every field is optional; what you leave out keeps its default. The host passes the file to\n `mountHost({ ui })`, and every module on the kit follows it at once.\n- ⚠ **Never restyle a catalog module by editing it.** An edited module is marked \"customized\" and\n stops getting catalog updates. Colours, radii, fonts, animation speed and sounds are all here.\n- **One theme for the WHOLE app:** sign-in (the SDK's `DefaultLoginScreen`, mounted by\n `src/main.tsx`), the lobby, every system and the host's own screens all draw from\n these tokens. A new look — even a light theme — is an edit of this file ONLY: never recolour the\n sign-in, the logo, icons or `index.html` by hand (the logo and the sign-in icons are painted with the\n theme's colours, the page background follows `bgBottom`).\n- **The default look is Layer Lab \"Vertical Casual\"** (react ≥ 0.6): bright blue-violet screens,\n blue cards, a heavy dark **ink** outline around buttons, panels and letters, glossy plates with a\n darker bottom lip, white outlined letters, the Rubik font (shipped inside the kit, EN + RU — no CDN).\n- Theme tokens: `bgTop`, `bgBottom`, `panel`, `panelDeep`, `panelEdge`, `text`, `textDim`, `gold`,\n `goldDeep`, `green`, `greenDeep`, `blue`, `blueDeep`, `red`, `redDeep`, `purple`, `purpleDeep`,\n `orange`, `orangeDeep`, `teal`, `tealDeep`, `ink` (the outline colour), `outline` (its thickness,\n px), `nav` (the bottom menu bar), `shadow`, `backdrop`, `radius`, `buttonRadius`, `font`, `rarity`\n (rarity id → plate colour; Common blue, Uncommon teal, Rare green, Epic purple, Legendary orange,\n Mythic red).\n- **Derived tokens — usually leave them out.** They are computed from the colours above: set a light\n `panel` and dark `text`, and the ink outline of the letters disappears (`textOutline`,\n `textStroke`), panels get a soft frame instead of the ink one (`panelEdge`), lines and recesses\n turn faint-dark instead of white/black (`line`, `well`, `wellSoft`, `wellShadow`, `edge`), cards keep a\n light inner edge (`panelShadow`, never a drop under them), and buttons of a pastel tone get dark text (`onGreen`, `onGold`,\n `onBlue`, `onRed`). Set one only to override the computed value.\n- A light theme is just colours:\n `theme: { bgTop: \"#f4fcf8\", bgBottom: \"#d9f0e6\", panel: \"#ffffff\", panelDeep: \"#f1faf5\",\npanelEdge: \"#bfe6d3\", text: \"#1f3b30\", textDim: \"#5f7d71\", green: \"#7ee0b5\", greenDeep: \"#4cc493\" }`.\n- Motion: `enabled`, `speed` (2 = twice as fast), `intensity` (0 = no bounce), `rewardFlight`,\n `particles`, `stagger`, `respectReducedMotion`.\n- Sound events: `click`, `tab`, `open`, `close`, `reward`, `coin`, `purchase`, `claim`, `error`,\n `notify`, `lootbox`, `levelUp`, `countTick`. Each: another event's name, `{ url }`, `{ synth: {\nnotes: [{ freq, dur, wave }] } }` or `false`.\n- The PLAYER's own sound / volume / \"fewer animations\" live on the device (the profile module draws\n them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA module registers a **feature**; whoever draws the menu shows it — the base's lobby as tabs (phone:\n4 + \"More\", tablet and desktop: more, gathered in the middle of the bar — a full-width bar at the bottom on every width;\nthe base takes the shared-UI role `\"menu\"`), or the\nhost's floating ☰ when nobody takes the role. Tab order and main tabs can be overridden in\n`src/app.config.ts` (`lobby.order`, `lobby.primary`).\n\n```ts\n// module.ts\nsetup(ctx) {\n ctx.features.register({\n id: \"forge\", label: \"Forge\", icon: \"hammer\", group: \"economy\", order: 60,\n primary: false, // true = a main tab of the lobby\n Screen: ForgeScreen, // (props: FeatureScreenProps) => ReactNode; props.args, props.close()\n available: false, // hidden until the title has something for it — see below\n fullBleed: false, // true = the screen paints edge to edge (its own background, a stage):\n // the lobby gives it the whole area, no padding, no width limit\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n // The title has forge recipes → show the tab; the reason is for the AI Coder's agent only:\n ctx.features.setAvailable(\"forge\", hasRecipes(configSection(ctx.client, \"Craft\")), \"no forge recipes\");\n}\n```\n\n- **Width.** A plain screen is a page: the lobby pads it and gives it the full width — from a 1440px\n window on, a centred 1120px column (the `UScreen` pages then get ink sides). Build it to fit any\n width from 320px, never fixed widths; keep a small block (a week calendar, a form) to its own\n `maxWidth` and centre it. A screen with its own backdrop (a hero stage, a map) registers\n `fullBleed: true` and fills the area itself (its root `flex: \"1 0 auto\"`; it gets the full height,\n under the lobby's top row too — keep its content below `var(--idos-lobby-top)`) — never negative\n margins to escape the padding. The lobby's own background — white see-through gear icons over\n the theme — moves (`screenBackground` + `className=\"idos-screen\"`): a page leaves it see-through\n rather than painting its own. The menu is a full-width bar at the bottom on every width, \"Play\"\n in its middle on a raised gold plate.\n- **Phone, tablet and PC at once.** A list never stretches one row across a wide window: put it in\n `Columns` — `kind=\"cards\"` (cards in as many columns of `min` px as fit), `kind=\"strips\"`\n (edge-to-edge rows on a phone, framed cards side by side from 700px), `kind=\"ranked\"` (an\n ordered list running DOWN one column, then the next — a leaderboard, a level road). It reacts to\n its own width (a container query), so it fits a page, a window or a side panel alike. A heading\n or a button inside it spans the row with `gridColumn: \"1 / -1\"`. A screen of two kinds of\n content (a profile, a referral code + its rewards) is two blocks in one `Columns`.\n- **Decorations never size a row.** Rays, glows and floating icons sit `position: absolute` over\n the line they belong to; only the line's content sets its height.\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. It returns `false` when the module is not installed or its\n tab is hidden (`ctx.features.get(\"store\")?.available`). From a game (the lobby is off screen) the\n host shows the screen in a window.\n- A screen that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows — from the client config\n (`configSection(client, \"Store\")`) or a read it makes anyway (a badge watcher); no extra request\n at start-up: every module's first reads arrive together and brush the per-player rate limit.\n- Groups: `economy`, `events`, `social`, `profile`. Icons are kit glyph names (`shop`, `hero`,\n `bag`, `trophy`, `gift`, `hammer`, `market`, `offer`, `flame`, `friends`, `chat`, `link`,\n `swords`, `user`, `bulb`, …) or an image URL.\n- Something that must be on screen all the time (a daily auto-popup, an overlay) is a panel:\n `ctx.registerPanel({ id, slot: \"modal\", activeOnly: false, component })`. Background work with\n NOTHING to draw - keeping a badge in step, a poll - is NOT a panel: use `ctx.effect(() => { …; return\ncleanup })` / `ctx.every(ms, run)` in `setup()` (cleaned up on logout).\n- **A badge must go out the moment the reward is taken.** Count it in a `ctx.effect` watcher from the\n CURRENT cache on every change — `client.on(\"user:anyUpdated\", update)` reading\n `client.data.user.state` — not in an effect keyed on a state object (the cache patches it in place,\n so such an effect does not re-run and the badge stays lit). A badge counted from data the screen\n re-reads (a storefront, active events) also listens to that read's event\n (`store:storefrontLoaded`…), and a store that several places reload runs one read at a time and keeps\n what it has when a read fails — see `modules/events` / `modules/deals`.\n- **Never re-read at start-up what the login already brought** — read through the central cache\n (`client.cache`, core ≥ 0.21). State containers: `client.cache.ensureState([\"Quest\"])` (no request\n while the container's `Rev` is confirmed; the server only when it must compute something — a new\n quest cycle, a hero given by default); a screen that wants changes made elsewhere passes\n `{ maxAgeMs: 60_000 }`. Definitions: `client.x.getDefinitions()` answers from the title config cache.\n Server views (storefront, active events/deals/season, unread counters, leaderboard progress) are\n cached by their service — call them freely, and take the data FROM THE ANSWER: the login usually\n brings them before a watcher subscribes, and a read the cache answers emits no `*Loaded` event. A game's own server data: `defineView` + `client.cache.read`.\n- A section the engine does not put into the client config (`TimedBoost`, `Marketplace`, `Match`,\n `AI`) is read with the service's own `getDefinitions()` — it lands in the same config section and is\n kept under the config version, so the next login does not ask again.\n- Auto-popups (`<Popup queued>`) wait while a game mode is on screen and show in the lobby.\n\n## Building a screen on the kit\n\nKeep the screen a short list; put every detail in a **popup**. Form controls: `TextInput` / `TextArea` (the kit's text field in the theme's colours - pass `style` only for width/size), `Select` (a drop-down in the same look, `<option>`s as children; react ≥ 0.5) and `Field` (a label above a control); do not hand-style an `<input>` or a `<select>`. A choice of a few options is `Tabs` (pass `style={{ flexWrap: \"wrap\" }}` in a narrow column).\n\n```tsx\nimport {\n Button,\n Popup,\n ResourceList,\n Tabs,\n useCelebrate,\n useToast,\n useUiKit,\n errorText,\n grantedBy,\n panel,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\n\nfunction ForgeScreen(): ReactNode {\n const [open, setOpen] = useState<string | null>(null);\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {/* rows… onClick={() => setOpen(id)} */}\n {open ? (\n <Popup title=\"Forge\" onClose={() => setOpen(null)} width={420}>\n …\n </Popup>\n ) : null}\n </div>\n );\n}\n```\n\n- `Popup` is a centred window on every screen — the blue body, a lighter title bar, the slate ✕\n sticking out of the top-right corner (`variant=\"sheet\"` makes it a bottom sheet); it animates in\n and out and plays open/close sounds. Close from inside with\n `usePopupClose()`. `ConfirmPopup` for yes/no. A popup that opens BY ITSELF (not on a tap) passes\n `queued` — auto-popups then show one at a time instead of stacking.\n- `useCelebrate()(lines)` shows \"You got\" and flies the icons to any element marked\n `data-resource-counter=\"{currencyID}\"` (the lobby's balances are). Build the lines\n from a response with `grantedBy(data)`; a reward with premium (VIP) bundles needs them added.\n- `useUiKit().play(\"claim\")`, `useUiKit().fx.burst(element)` — a sound and a particle burst.\n- **Notifications — `useNotify()`** (the same system as a module's `ctx.notifications`):\n `notify.toast({ level: \"success\", title: \"Saved\" })` — a toast at the top that goes by itself;\n `await notify.alert({ level: \"warning\", title, message, actions })` — a window the player must\n answer, queued one at a time. Levels `info | success | warning | error | reward` pick the colour,\n icon, sound and how long a toast stays (errors longest). `icon` (a glyph, emoji or URL), `image` (a\n thumbnail / a big picture in the window), `source` (a feature id — its name on the card, a tap opens\n it), `key` (the same key replaces instead of stacking; identical toasts merge into \"×2\"),\n `duration` (`0` — until closed), `actions`. Refusals: `notify.toast({ level: \"error\", message:\nerrorText(res.error) })`; never re-check server rules. `useToast()(text, kind)` is the old short\n form of the same thing.\n- Lines of resources: `grantLines(grant)`, `costLines(cost)`, `bundleLines(bundle)`,\n `canAfford(lines, balances)`, `<ResourceList lines={…} />`. Names and icons: `useCatalog()`.\n- A crypto balance is ONE number — the backed crypto plus its IOU (`{ID}_IOU`, the unbacked part of\n rewards; it spends like the crypto): `cryptoBalance(state, id, catalog.cryptoCurrencies)` →\n `{ backed, unbacked, total }`, `<CryptoBalanceChip currencyID=\"Main\" />` (the total, the split in a\n hover/tap popover; the lobby's top row shows it). Never draw `{ID}_IOU` as a currency of its own\n (`iouCurrencies(...)` lists them), never round a crypto amount to whole numbers (`formatCryptoAmount`).\n- Words: `makeT(EN, RU)` for your strings, `plural(n, \"лот|лота|лотов\")` for counts. A table answers\n in the CURRENT language at every call, so a module-level `t` follows the language switch.\n- Interface language: the player's choice — `setLanguage(\"en\" | \"ru\")`, `LanguageSwitch` (English /\n Русский, already in the profile screen and the base's account popup), `useLanguage()` re-renders a\n component on change, `onLanguageChange(fn)`. Saved in localStorage `idos:language`; the window\n event `idos:language` tells games without the kit (VoxelCraft `src/i18n.ts` listens and fires it\n from its pause menu). Tab labels follow only as functions: `label: () => t(\"title\")`.\n- ⚠ A root `display: grid` with a wide child (a row of tabs) grows past a phone screen — give it\n `gridTemplateColumns: \"minmax(0, 1fr)\"`.\n- **The pieces of the casual sheet** — use them instead of drawing your own:\n - buttons: `Button tone=` `green | gold | blue | red | grey | purple | orange | teal | white |\nsilver | slate` (`gold` = the big \"Play\"/\"STAGE\", `grey` = \"Cancel\", `white` = blue letters);\n `IconButton` (a square slate plate — close, menu), `InfoButton` (the round \"i\"), `BackButton`\n (the slanted \"◀\" tab at the screen's left edge), `MenuTile` (an icon over its name on a dark-blue\n plate — \"Ranking\", \"Mission\");\n - frames: `ScreenHeader` (back + title + whatever goes right), `SectionTitle tone=` (a ribbon\n banner with a swallowtail — shop sections in turn red / green / purple / gold), `Ribbon` (a\n centred ribbon with folded tails and stars), `listRow` (a strip a shade deeper than the panel —\n letters, friends), `IconTile` (a square light tile holding an icon or a face), `rarityPlate(colour)`\n / `tonePlate(tone)` (a glossy plate in the ink frame);\n - values: `ProgressBar tone=` (a dark track, a glossy fill, \"275/100\" on it), `LevelShield`,\n `LevelBar` (the shield over the bar's left end), `HexBadge`, `StepperCounter` (\"29 / 100 +\"),\n `ResourceCounter` (the icon overlapping a dark plate), `RarityTag`, `SoldOutStamp`,\n `Toggle` (ON / OFF), `SpeechBubble`, `LevelRoadRow` (one step of a level road: `done | current |\nnext | locked`, its reward in a hexagon);\n - text: `outlined` (the theme's text with its ink outline), `inked` (white letters with the ink\n outline on ANY plate). `Tabs` are the white-active segmented strip (\"Daily | Challenge\").\n- ⚠ **No colour literals in a screen** (`\"#fff\"`, `\"rgba(0,0,0,.3)\"`, `\"#5a3fd0\"`): they break the\n moment the theme changes (white text on a light panel, a black stripe under a pastel button). Use\n the tokens: text `v.text` / `v.textDim` (or `...outlined`), surfaces `panel` / `well` / `v.wellSoft`,\n lines `v.line`, the outline `v.ink` / `v.outline`, dimming `v.backdrop`. A coloured banner, \"your\n row\", a highlighted day → `...toneSurface(tone)` (any button tone): it fills with that tone AND switches the text\n inside it to the tone's own readable colour. Text on a filled button — `ON_TONE[tone]`. A blend:\n `` `color-mix(in srgb, ${v.gold} 20%, transparent)` ``.\n\n## Prices in Aspis (the platform currency)\n\nA price option can be in Aspis (`PlatformCredits`, API name Credits) in every sale of the game's author: lootboxes,\npacks, craft, deals, boosts, VIP, upgrades, skins, coop spins, InApp AI, the Workshop publish fee. The player confirms\neach spend on idosgames.com; the game cannot charge it by itself.\n\n- Wrap the module's own call — it takes the confirmation ID as its last argument / `creditsConfirmationID` option:\n\n ```ts\n import { payWithCredits, errorText } from \"@idosgames/react/ui\";\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.lootbox.open(boxID, 1, optionID, undefined, creditsConfirmationID),\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n ```\n\n A price without Aspis passes straight through (one request). With Aspis the site shows its window over the game on\n idosgames.com; anywhere else the kit shows its popup with the link (`CreditsConfirmLayer`, already in\n `renderKitOverlays`). Closing the popup cancels (`CREDITS_CONFIRMATION_CANCELLED`); `NOT_ENOUGH_CREDITS` also\n opens the top-up page. Not a hook: works from a game session or a Phaser scene too.\n- ⚠ **One confirmation, never two**: no game popup \"Buy for 50 Aspis?\" before it.\n- A method that waits for the confirmation itself (`client.workshop.publish({ credits })`): `const k =\n kitCreditsPrompt(client)`, pass `credits: k.options`, then `await k.done(res)`.\n- Showing the price: `costLines` gives a line of kind `credits` (icon and name \"Aspis\" come from `useCatalog` /\n `resourceName`), `canAfford` leaves it to the server, `creditsPriceOf(cost)` reads the amount, `CreditsPrice` draws\n \"[icon] 50 Aspis\". Batches (\"upgrade all\", \"×5\") do not take Aspis: leave such options out of them.\n- `errorText` names every `CREDITS_*` refusal; never a reward in Aspis.\n\n## Tutorial hints\n\nThe `tutorials` module highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs\nare `tab:{featureID}`, its profile button `lobby:profile`, its wallet `lobby:wallet`, a game's \"Play\"\nbutton `play:{modeID}`. Mark the controls of your own screen the same\nway; the publisher points tutorial steps at them in LiveOps (Tutorial → step → AnchorID).\n\n## Fast on a cheap phone\n\n- **No SVG or CSS `filter` on anything that sits in a part of the screen that repaints** (a counter, a\n timer, a progress bar next to it). Chrome runs the filter again on the CPU at the screen's density\n on every repaint: the kit's icons once had an `feMorphology` outline and 24 of them cost a phone half\n its frames. Outlines are geometry (the kit's `Icon` draws its ink as a wider stroke under the shapes).\n- **Animate `transform` / `opacity` only**, never `background-position`, `width`, `box-shadow`, `filter` or\n colours in a loop: those repaint every frame (the drifting pattern behind kit screens is a `::before` moved\n by `transform`; `.idos-glow` is a shadow drawn once whose opacity pulses — use it, don't animate a glow yourself).\n- A Phaser game draws at the screen's density (`min(2, devicePixelRatio)`) with the camera zoomed by\n it, keeps `render.autoMobileTextures: false`, and sizes its canvas to what is visible (see the\n idle-rpg module's `phaser/density.ts` and controller). See `docs/feature-history/mobile-game-frame-perf.md`.\n\n## Size on a phone, and no shadows\n\n- **The kit's look is the ink outline only: no shadows.** No outer `box-shadow` / `text-shadow` drop\n (`0 3px 0 …`) and no `drop-shadow(0 Npx …)` filter under buttons, cards, icons or letters. Allowed:\n `inset` lips and highlights, a ring `0 0 0 Npx` (an outline), a glow `0 0 Npx`. Letters are outlined by\n `textStroke` alone; `theme.shadow` is transparent.\n- **A screen surface carries `className=\"idos-fit\"`** and is drawn with CSS `zoom` = `--idos-ui-zoom` (1 on a\n tablet or PC, 0.75…1 on a phone by its size against 430×860, `fit.ts`). The kit's popups, windows and toasts,\n the lobby, the frame around a game and the games' DOM panels already have it. A new full-screen DOM panel\n of a game puts it on its own root (or on the part over the canvas). ⚠ Never on a canvas host: a canvas\n inside a surface sits in `className=\"idos-fit-off\"`.\n- Inside a surface, `getBoundingClientRect()` is in screen pixels, `clientWidth` / `scrollTop` / `style.left`\n in the surface's own: convert with `fitScale(el)`. A canvas counts its pixels as\n `clientWidth × el.currentCSSZoom × dpr`. See `docs/feature-history/ui-kit-fit-no-shadows.md`.\n",
5
5
  "references": []
6
6
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "store-system",
3
3
  "description": "Build a store / shop screen in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load the RESOLVED storefront for the current player (rotating daily shops, sections, slots, badges, refresh timers, remaining limits), load the offer catalogue, and purchase one or many offers. Prices can be virtual currency, crypto, a real money store product, a rewarded-video ad credit, or free. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or otherwise touches client.store, StoreService, GetStorefrontResponse, StoreDefinitions or offer purchasing — even if they don't name the module.",
4
- "content": "---\nname: store-system\ndescription: >-\n Build a store / shop screen in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load the RESOLVED\n storefront for the current player (rotating daily shops, sections, slots,\n badges, refresh timers, remaining limits), load the offer catalogue, and\n purchase one or many offers. Prices can be virtual currency, crypto, a real\n money store product, a rewarded-video ad credit, or free. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem\n packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or\n otherwise touches client.store, StoreService, GetStorefrontResponse,\n StoreDefinitions or offer purchasing — even if they don't name the module.\n---\n\n# Store system (iDosGames TS SDK)\n\nA title sells **offers** (products) placed on **shelves**. The shop is a tree:\n\n```\nStore (storefront)\n└── Layout.Sections\n └── Slots ← a slot is a PLACE on the shelf\n └── the slot decides which offer stands in it\n```\n\n⚠⚠ **A slot is the only address a product can be bought through.** An offer\nlives in a flat catalogue (`StoreOffers`) and becomes buyable only by standing\nin a slot; a product placed in no slot **cannot be bought at all**. There is no\n\"list of stores\" on the product — that field was removed, because it was never\na right to buy: the purchase never checked it.\n\nEverything is **server-authoritative**. The client asks to purchase, the\nbackend validates price, windows, gates, limits **and the rotation roll**, then\nthe SDK mirrors the confirmed result into a local cache your UI reads.\n\n## ⚠ Draw `getStorefront()`, not `getDefinitions()`\n\nThis is the single most important rule of the module.\n\n`getDefinitions()` returns the **config** — identical for every player.\n`getStorefront()` returns the shop **resolved for this player**: rotation\nalready rolled, gates applied, badges and refresh timers computed, remaining\nlimits filled in. None of that can be derived on the client:\n\n- **Rotation** is recomputed by the server on every read and stored nowhere. A\n client rolling it itself would show two devices two different shops.\n- **Counters** (`PurchasedTotal`, remaining caps, cooldown) belong to the\n player and are not in the config.\n- **Gates** filter storefronts, sections, slots and pool entries per player.\n\nUse `getDefinitions()` only for catalogue-wide tooling (an admin view, a\nsearch). A shop screen never needs it.\n\n## Prices: six kinds, one shape\n\nPrice is always a `PriceOptions` dictionary (`Pricing.Options`), the\nplatform-wide standard. An option's cost can be:\n\n| Kind | What it is | On the storefront view |\n| --------------------------------------- | ------------------------------------------- | ------------------------------- |\n| Virtual currency / items / event tokens | ordinary `ResourceConsume` | — |\n| Crypto | a crypto currency entry | — |\n| Real money | a store product entry; needs a receipt | `IsStorePaid: true` |\n| Rewarded video | an ad-credit entry, spent like any resource | `IsAdPaid: true` |\n| **Credits** (platform currency) | a `PlatformCredits` entry, `Amount` = price | `IsCreditsPaid`, `CreditsPrice` |\n| **Free** | **no cost at all** | `IsFree: true` |\n\n⚠ **An unset cost means FREE, and that is a legal product** — not an unfilled\nprice. A free offer must be limited on some axis (`TotalCap`, `DailyCap`,\n`CooldownSeconds`, or the slot's `PerInstanceCap`); without a limit the server\nrefuses the purchase, because it would be a button that grants itself forever.\n\n⚠ Ad-paid offers spend a **credit the player already earned** by watching a\nvideo (the Advertising module grants it server-side). Read the balance from\n`storefront.AdCreditBalance` to decide whether a card shows \"Watch\" or \"Buy\".\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 store = client.store; // the StoreService\n```\n\nEvery store method requires an authenticated session. Without one they return\n`{ 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`.\n\n| Method | Purpose | `data` on success |\n| ------------------------------------- | ---------------------------------------- | ----------------------- |\n| `getStorefront()` | **The shop as this player sees it now.** | `GetStorefrontResponse` |\n| `getDefinitions()` | The raw catalogue (config). | `StoreDefinitions` |\n| `getUserState()` | Purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` |\n\n`options` = `{ selectedOptionID?, payment?, slot? }`.\n\n`purchase` clamps `count` server-side to **1–100**. `purchaseBatch` takes\n`StorePurchaseRef[]`, deduped by `OfferID`, clamped to **50 refs per call**.\n\n## Drawing a shop screen\n\n```ts\nconst res = await client.store.getStorefront();\nif (!res.ok) return showError(res.error);\nconst front = res.data;\n\nfor (const store of front.Stores ?? []) {\n for (const section of store.Sections ?? []) {\n for (const slot of section.Slots ?? []) {\n // ⚠ An EMPTY slot arrives with Offer === null and must still be drawn as\n // an empty frame. Skipping it makes the shelf jump around whenever a\n // window closes or a gate filters the pool — which is exactly why the\n // server sends the slot rather than omitting it.\n if (!slot.Offer) {\n drawEmptyCard(slot.RefreshesAtUtc); // \"opens in ...\"\n continue;\n }\n\n const offer = slot.Offer;\n const state = offer.State;\n\n drawCard({\n title: offer.Identity?.DisplayName, // localization key OR a literal\n ribbons: offer.Identity?.Tags, // static badges: best_value, x2 ...\n // Server-computed badges — do not recompute these:\n firstPurchase: state?.IsFirstPurchaseAvailable,\n soldOut: state?.SoldOut,\n readyAt: state?.AvailableAtUtc, // free-slot cooldown\n // Rotation timer: \"Refreshes in 15h 40m\"\n refreshesAt: slot.RefreshesAtUtc,\n remainingThisWindow: slot.RemainingThisRotation, // null = unlimited\n prices: offer.PriceOptions, // already filtered to this platform\n });\n }\n }\n}\n```\n\n⚠ **Count timers down from `front.ServerTimeUtc`, not from the device clock.**\nThat is what it is for; a player with a skewed clock otherwise sees a shop that\nrefreshes at the wrong moment or claims to have already refreshed.\n\n⚠ **`null` and `0` mean different things in every remaining-count field.**\n`null` = \"no limit on this axis\"; `0` = \"spent, sold out\". Treating `null` as\nzero grays out every unlimited product on the shelf.\n\n`client.store.nextRefreshAtUtc()` returns the nearest refresh across all\nvisible slots — use it to schedule one reload instead of a polling loop.\n\n## Purchasing\n\n```ts\nconst res = await client.store.purchase(offer.OfferID, 1, {\n selectedOptionID: option.OptionID,\n // Pass the slot the player tapped — the storefront gives you all three ids.\n slot: {\n storeID: store.StoreID,\n sectionID: section.SectionID,\n slotID: slot.SlotID,\n },\n});\nif (!res.ok) return showError(res.error);\n```\n\nAn option paid in a store needs the receipt as well:\n\n```ts\nawait client.store.purchase(\"gems_l\", 1, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n⚠ **The slot address is optional as a whole** (all three or none): without it\nthe server finds the slot itself. The roll is re-checked either way, so a\nproduct that rolled for the player nowhere cannot be bought with or without an\naddress. Pass it anyway when you have it — for a pooled product it removes the\nambiguity of which slot's per-window cap applies.\n\n### Credits — the platform currency (`IsCreditsPaid`)\n\n⚠ **Players see this currency as \"Aspis\"** (owner's decision 26.10.2026) — write \"Aspis\" in any player-facing text\n(price \"100 Aspis\", \"Not enough Aspis\"). \"Credits\" is only the API name (`PlatformCredits`, `purchaseWithCredits`,\n`CreditsError`, `IsCreditsPaid`); do not name a game's own currency \"Coins\" next to it either — players confuse them.\n\nCredits live on the player's **iDos Games account**, not in the game; the game's author gets 70% of the price, the\nplatform 30%. ⚠ The game **cannot charge Credits by itself**: the first call only creates a purchase request, the\nplayer confirms it on idosgames.com with their own account, and only then the purchase completes. On idosgames.com's\nown game page the SITE shows its confirmation window right over the game — `purchaseWithCredits` handles that by itself\nand `onConfirmation` is not called; on the game's own address or `/embed` it falls back to the link. ⚠ **One\nconfirmation, never two** (owner, 04.11.2026): a tap on an Aspis price calls `purchaseWithCredits` AT ONCE — no game\npopup \"Confirm the purchase\" before it; the site's window is the confirmation. Show your own popup only from\n`onConfirmation` (the site could not ask), with the link. Reference: `modules/store` `StoreScreen.buyWithCredits`.\nUse the one call that does the whole flow:\n\n```ts\nimport { CreditsError } from \"@idosgames/core\";\n\nconst res = await client.store.purchaseWithCredits(offer.OfferID, 1, {\n selectedOptionID: option.OptionID,\n slot: { storeID, sectionID, slotID },\n // Show a LINK/button to view.Url (target=\"_blank\") — not window.open after an await: browsers block that.\n onConfirmation: (view) => showConfirmLink(view.Url),\n signal: abort.signal, // stop waiting when the popup closes\n});\nif (!res.ok && res.error === CreditsError.NotEnough) {\n const b = await client.store.getCreditsBalance(); // b.data.TopUpUrl — where to top up\n}\n```\n\n- Price the option in Credits in LiveOps → Store (resource picker → **Credits**). Only the Store accepts Credits; one Credits\n entry per option, never together with a store product, never as a reward.\n- Refusals (`CreditsError`): `CREDITS_LOGIN_REQUIRED` (guest — sign in with iDos Games), `NOT_ENOUGH_CREDITS`,\n `CREDITS_CONFIRMATION_DECLINED` / `_EXPIRED`, `CREDITS_BATCH_FORBIDDEN` (no Credits in `purchaseBatch`).\n- `getCreditsBalance()` — Credits the player can spend in games (bought + earned; bonus Credits are for platform services).\n- On the DEV copy of the title a Credits purchase is a free test: no request, no Credits moved.\n\n### ⚠ `OFFER_NOT_IN_ROTATION` means \"reload the shop\", not \"show an error\"\n\nThe rotation roll is recomputed, never stored, so editing a slot's pool\nreshuffles the shelf for everyone who has it open. A player who taps a card\nthat is no longer in their roll gets exactly this error:\n\n```ts\nimport { OFFER_NOT_IN_ROTATION } from \"@idosgames/core\";\n\nif (!res.ok && res.error === OFFER_NOT_IN_ROTATION) {\n await client.store.getStorefront(); // silently redraw; do not toast\n return;\n}\n```\n\nAlready-completed purchases are never undone by a reshuffle — counters are\naddressed by product and slot, not by the roll.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:storefrontLoaded` → `GetStorefrontResponse`\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write.\n\n## Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) applyOk(item.Id);\n else showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` says the call ran, each\nelement's `Success`/`Error` says whether that item applied. Items rejected on\ntheir own merits (unknown id, outside window, gate failed, limit reached, not\nin rotation) are filtered out _before_ the merged charge is built. The\nsurvivors are then charged as **one merged, all-or-nothing transaction**.\n\n⚠ An option paid in a **store** cannot go in a batch — one receipt pays for one\npurchase. Ad credits can: a credit is an ordinary resource and two of them add\nup like two coins.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` mints a fresh idempotency\n key, so two calls are two real operations — a double-clicked \"Buy\" charges\n twice. Disable the control while a call is in flight.\n- **The struck-through price is display only.** `CompareAtCost` never reaches\n the charge. `DiscountPercent` is computed by the server and is `null` when it\n cannot be computed (a mixed bundle has no single percentage) — `null` is not\n zero, and printing \"0%\" there is a lie.\n- **Prices and rewards can be adjusted server-side.** Premium tiers apply\n discounts and bonuses automatically; read what was actually charged and\n granted off `res.data.Resources`, not off the displayed base values.\n- **`count` scales cost and rewards linearly, then premium is applied once** —\n it is not N independent purchases.\n- **Only one `Resources` apply per batch call**, but every item's own\n `OfferID`/`Count`/`ServerTimeUtc` is correct and drives its counter patch.\n- **`DailyPurchases` resets on UTC midnight.** Read `DailyResetUtc` from the\n cache after a call rather than computing it.\n- **Tags are for the client; player-dependent badges are not.** Anything whose\n truth depends on the player — first purchase, sold out, a timer — arrives in\n `State`, computed by the server. Do not encode those as tags.\n- **`DisplayName` is a localization key OR a literal.** Resolution is\n `t(x) = own table → fallback table → x itself`, so a title with literal\n names works with no migration. Run these through the localization module.\n- **Render from the cache, handle the error from the result.** Use `reason` to\n decide behaviour (retry on `\"connection\"`, re-auth on `\"unauthorized\"`, toast\n the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstorefront field, the rotation modes, the purchase-limit/reset rule matrix, and\nbatch semantics. Read it when building config-driven UI or when an error points\nat a rule you need to understand.\n",
4
+ "content": "---\nname: store-system\ndescription: >-\n Build a store / shop screen in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load the RESOLVED\n storefront for the current player (rotating daily shops, sections, slots,\n badges, refresh timers, remaining limits), load the offer catalogue, and\n purchase one or many offers. Prices can be virtual currency, crypto, a real\n money store product, a rewarded-video ad credit, or free. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem\n packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or\n otherwise touches client.store, StoreService, GetStorefrontResponse,\n StoreDefinitions or offer purchasing — even if they don't name the module.\n---\n\n# Store system (iDosGames TS SDK)\n\nA title sells **offers** (products) placed on **shelves**. The shop is a tree:\n\n```\nStore (storefront)\n└── Layout.Sections\n └── Slots ← a slot is a PLACE on the shelf\n └── the slot decides which offer stands in it\n```\n\n⚠⚠ **A slot is the only address a product can be bought through.** An offer\nlives in a flat catalogue (`StoreOffers`) and becomes buyable only by standing\nin a slot; a product placed in no slot **cannot be bought at all**. There is no\n\"list of stores\" on the product — that field was removed, because it was never\na right to buy: the purchase never checked it.\n\nEverything is **server-authoritative**. The client asks to purchase, the\nbackend validates price, windows, gates, limits **and the rotation roll**, then\nthe SDK mirrors the confirmed result into a local cache your UI reads.\n\n## ⚠ Draw `getStorefront()`, not `getDefinitions()`\n\nThis is the single most important rule of the module.\n\n`getDefinitions()` returns the **config** — identical for every player.\n`getStorefront()` returns the shop **resolved for this player**: rotation\nalready rolled, gates applied, badges and refresh timers computed, remaining\nlimits filled in. None of that can be derived on the client:\n\n- **Rotation** is recomputed by the server on every read and stored nowhere. A\n client rolling it itself would show two devices two different shops.\n- **Counters** (`PurchasedTotal`, remaining caps, cooldown) belong to the\n player and are not in the config.\n- **Gates** filter storefronts, sections, slots and pool entries per player.\n\nUse `getDefinitions()` only for catalogue-wide tooling (an admin view, a\nsearch). A shop screen never needs it.\n\n## Prices: six kinds, one shape\n\nPrice is always a `PriceOptions` dictionary (`Pricing.Options`), the\nplatform-wide standard. An option's cost can be:\n\n| Kind | What it is | On the storefront view |\n| --------------------------------------- | ------------------------------------------- | ------------------------------- |\n| Virtual currency / items / event tokens | ordinary `ResourceConsume` | — |\n| Crypto | a crypto currency entry | — |\n| Real money | a store product entry; needs a receipt | `IsStorePaid: true` |\n| Rewarded video | an ad-credit entry, spent like any resource | `IsAdPaid: true` |\n| **Credits** (platform currency) | a `PlatformCredits` entry, `Amount` = price | `IsCreditsPaid`, `CreditsPrice` |\n| **Free** | **no cost at all** | `IsFree: true` |\n\n⚠ **An unset cost means FREE, and that is a legal product** — not an unfilled\nprice. A free offer must be limited on some axis (`TotalCap`, `DailyCap`,\n`CooldownSeconds`, or the slot's `PerInstanceCap`); without a limit the server\nrefuses the purchase, because it would be a button that grants itself forever.\n\n⚠ Ad-paid offers spend a **credit the player already earned** by watching a\nvideo (the Advertising module grants it server-side). Read the balance from\n`storefront.AdCreditBalance` to decide whether a card shows \"Watch\" or \"Buy\".\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 store = client.store; // the StoreService\n```\n\nEvery store method requires an authenticated session. Without one they return\n`{ 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`.\n\n| Method | Purpose | `data` on success |\n| ------------------------------------- | ---------------------------------------- | ----------------------- |\n| `getStorefront()` | **The shop as this player sees it now.** | `GetStorefrontResponse` |\n| `getDefinitions()` | The raw catalogue (config). | `StoreDefinitions` |\n| `getUserState()` | Purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` |\n\n`options` = `{ selectedOptionID?, payment?, slot? }`.\n\n`purchase` clamps `count` server-side to **1–100**. `purchaseBatch` takes\n`StorePurchaseRef[]`, deduped by `OfferID`, clamped to **50 refs per call**.\n\n## Drawing a shop screen\n\n```ts\nconst res = await client.store.getStorefront();\nif (!res.ok) return showError(res.error);\nconst front = res.data;\n\nfor (const store of front.Stores ?? []) {\n for (const section of store.Sections ?? []) {\n for (const slot of section.Slots ?? []) {\n // ⚠ An EMPTY slot arrives with Offer === null and must still be drawn as\n // an empty frame. Skipping it makes the shelf jump around whenever a\n // window closes or a gate filters the pool — which is exactly why the\n // server sends the slot rather than omitting it.\n if (!slot.Offer) {\n drawEmptyCard(slot.RefreshesAtUtc); // \"opens in ...\"\n continue;\n }\n\n const offer = slot.Offer;\n const state = offer.State;\n\n drawCard({\n title: offer.Identity?.DisplayName, // localization key OR a literal\n ribbons: offer.Identity?.Tags, // static badges: best_value, x2 ...\n // Server-computed badges — do not recompute these:\n firstPurchase: state?.IsFirstPurchaseAvailable,\n soldOut: state?.SoldOut,\n readyAt: state?.AvailableAtUtc, // free-slot cooldown\n // Rotation timer: \"Refreshes in 15h 40m\"\n refreshesAt: slot.RefreshesAtUtc,\n remainingThisWindow: slot.RemainingThisRotation, // null = unlimited\n prices: offer.PriceOptions, // already filtered to this platform\n });\n }\n }\n}\n```\n\n⚠ **Count timers down from `front.ServerTimeUtc`, not from the device clock.**\nThat is what it is for; a player with a skewed clock otherwise sees a shop that\nrefreshes at the wrong moment or claims to have already refreshed.\n\n⚠ **`null` and `0` mean different things in every remaining-count field.**\n`null` = \"no limit on this axis\"; `0` = \"spent, sold out\". Treating `null` as\nzero grays out every unlimited product on the shelf.\n\n`client.store.nextRefreshAtUtc()` returns the nearest refresh across all\nvisible slots — use it to schedule one reload instead of a polling loop.\n\n## Purchasing\n\n```ts\nconst res = await client.store.purchase(offer.OfferID, 1, {\n selectedOptionID: option.OptionID,\n // Pass the slot the player tapped — the storefront gives you all three ids.\n slot: {\n storeID: store.StoreID,\n sectionID: section.SectionID,\n slotID: slot.SlotID,\n },\n});\nif (!res.ok) return showError(res.error);\n```\n\nAn option paid in a store needs the receipt as well:\n\n```ts\nawait client.store.purchase(\"gems_l\", 1, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n⚠ **The slot address is optional as a whole** (all three or none): without it\nthe server finds the slot itself. The roll is re-checked either way, so a\nproduct that rolled for the player nowhere cannot be bought with or without an\naddress. Pass it anyway when you have it — for a pooled product it removes the\nambiguity of which slot's per-window cap applies.\n\n### Credits — the platform currency (`IsCreditsPaid`)\n\n⚠ **Players see this currency as \"Aspis\"** (owner's decision 26.10.2026) — write \"Aspis\" in any player-facing text\n(price \"100 Aspis\", \"Not enough Aspis\"). \"Credits\" is only the API name (`PlatformCredits`, `purchaseWithCredits`,\n`CreditsError`, `IsCreditsPaid`); do not name a game's own currency \"Coins\" next to it either — players confuse them.\n\nCredits live on the player's **iDos Games account**, not in the game; the game's author gets 70% of the price, the\nplatform 30%. ⚠ The game **cannot charge Credits by itself**: the first call only creates a purchase request, the\nplayer confirms it on idosgames.com with their own account, and only then the purchase completes. On idosgames.com's\nown game page the SITE shows its confirmation window right over the game — `purchaseWithCredits` handles that by itself\nand `onConfirmation` is not called; on the game's own address or `/embed` it falls back to the link. ⚠ **One\nconfirmation, never two** (owner, 04.11.2026): a tap on an Aspis price calls `purchaseWithCredits` AT ONCE — no game\npopup \"Confirm the purchase\" before it; the site's window is the confirmation. Show your own popup only from\n`onConfirmation` (the site could not ask), with the link. Reference: `modules/store` `StoreScreen.buyWithCredits`.\nUse the one call that does the whole flow:\n\n```ts\nimport { CreditsError } from \"@idosgames/core\";\n\nconst res = await client.store.purchaseWithCredits(offer.OfferID, 1, {\n selectedOptionID: option.OptionID,\n slot: { storeID, sectionID, slotID },\n // Show a LINK/button to view.Url (target=\"_blank\") — not window.open after an await: browsers block that.\n onConfirmation: (view) => showConfirmLink(view.Url),\n signal: abort.signal, // stop waiting when the popup closes\n});\nif (!res.ok && res.error === CreditsError.NotEnough) {\n const b = await client.store.getCreditsBalance(); // b.data.TopUpUrl — where to top up\n}\n```\n\n- Price the option in Credits in LiveOps (resource picker of a PRICE → **Aspis**). Every sale of the game's author\n accepts Credits (owner, 05.11.2026): store, lootboxes, collection packs, craft, deals, boosts, VIP, item and\n character upgrades, skins, coop spins, the board's special mode, the Workshop publish fee, InApp AI (charged only\n when the generation is ready). NOT in player-to-player prices or refundable fees (marketplace listing fee, PvP\n match) — `CREDITS_NOT_ALLOWED_HERE`. One Credits entry per option, never together with a store product, never as a\n reward.\n- Outside the store the same flow is `client.credits.pay` around the module's own call, which takes the confirmation\n ID as its last argument / `creditsConfirmationID` option:\n `client.credits.pay((id) => client.lootbox.open(boxID, 1, option.OptionID, undefined, id), { onConfirmation, signal })`.\n A direct call with an Aspis option only returns `CreditsConfirmation` and changes nothing — check it first.\n In a screen built on the UI kit use `payWithCredits(client, (id) => ...)` from `@idosgames/react/ui` instead: the\n same flow plus the kit's own popup with the link and the top-up page on `NOT_ENOUGH_CREDITS` (skill\n `idosgames-ui-kit`, \"Prices in Aspis\").\n- Refusals (`CreditsError`): `CREDITS_LOGIN_REQUIRED` (guest — sign in with iDos Games), `NOT_ENOUGH_CREDITS`,\n `CREDITS_CONFIRMATION_DECLINED` / `_EXPIRED`, `CREDITS_BATCH_FORBIDDEN` (no Credits in `purchaseBatch`).\n- `getCreditsBalance()` — Credits the player can spend in games (bought + earned; bonus Credits are for platform services).\n- On the DEV copy of the title a Credits purchase is a free test: no request, no Credits moved.\n\n### ⚠ `OFFER_NOT_IN_ROTATION` means \"reload the shop\", not \"show an error\"\n\nThe rotation roll is recomputed, never stored, so editing a slot's pool\nreshuffles the shelf for everyone who has it open. A player who taps a card\nthat is no longer in their roll gets exactly this error:\n\n```ts\nimport { OFFER_NOT_IN_ROTATION } from \"@idosgames/core\";\n\nif (!res.ok && res.error === OFFER_NOT_IN_ROTATION) {\n await client.store.getStorefront(); // silently redraw; do not toast\n return;\n}\n```\n\nAlready-completed purchases are never undone by a reshuffle — counters are\naddressed by product and slot, not by the roll.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:storefrontLoaded` → `GetStorefrontResponse`\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write.\n\n## Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) applyOk(item.Id);\n else showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` says the call ran, each\nelement's `Success`/`Error` says whether that item applied. Items rejected on\ntheir own merits (unknown id, outside window, gate failed, limit reached, not\nin rotation) are filtered out _before_ the merged charge is built. The\nsurvivors are then charged as **one merged, all-or-nothing transaction**.\n\n⚠ An option paid in a **store** cannot go in a batch — one receipt pays for one\npurchase. Ad credits can: a credit is an ordinary resource and two of them add\nup like two coins.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` mints a fresh idempotency\n key, so two calls are two real operations — a double-clicked \"Buy\" charges\n twice. Disable the control while a call is in flight.\n- **The struck-through price is display only.** `CompareAtCost` never reaches\n the charge. `DiscountPercent` is computed by the server and is `null` when it\n cannot be computed (a mixed bundle has no single percentage) — `null` is not\n zero, and printing \"0%\" there is a lie.\n- **Prices and rewards can be adjusted server-side.** Premium tiers apply\n discounts and bonuses automatically; read what was actually charged and\n granted off `res.data.Resources`, not off the displayed base values.\n- **`count` scales cost and rewards linearly, then premium is applied once** —\n it is not N independent purchases.\n- **Only one `Resources` apply per batch call**, but every item's own\n `OfferID`/`Count`/`ServerTimeUtc` is correct and drives its counter patch.\n- **`DailyPurchases` resets on UTC midnight.** Read `DailyResetUtc` from the\n cache after a call rather than computing it.\n- **Tags are for the client; player-dependent badges are not.** Anything whose\n truth depends on the player — first purchase, sold out, a timer — arrives in\n `State`, computed by the server. Do not encode those as tags.\n- **`DisplayName` is a localization key OR a literal.** Resolution is\n `t(x) = own table → fallback table → x itself`, so a title with literal\n names works with no migration. Run these through the localization module.\n- **Render from the cache, handle the error from the result.** Use `reason` to\n decide behaviour (retry on `\"connection\"`, re-auth on `\"unauthorized\"`, toast\n the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstorefront field, the rotation modes, the purchase-limit/reset rule matrix, and\nbatch semantics. Read it when building config-driven UI or when an error points\nat a rule you need to understand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",