@idosgames/mcp 0.1.43 → 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.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +144 -144
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +15 -15
- package/registry/modules/boosts.json +5 -5
- package/registry/modules/character.json +11 -11
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +6 -6
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +6 -6
- package/registry/modules/events.json +7 -7
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +21 -17
- package/registry/modules/idle-rpg.json +20 -16
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/leaderboard.json +7 -7
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +7 -7
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +9 -9
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +9 -9
- package/registry/modules/voxelcraft.json +37 -29
- package/registry/modules/workshop.json +6 -6
- package/registry/skills/ai-generation-system.json +2 -2
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/rewarded-ads.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"version": "0.1.3"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@idosgames/core": "0.
|
|
43
|
-
"@idosgames/module-sdk": "0.13.
|
|
44
|
-
"@idosgames/react": "0.
|
|
42
|
+
"@idosgames/core": "0.26.0",
|
|
43
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
44
|
+
"@idosgames/react": "0.7.0",
|
|
45
45
|
"react": "19.2.7"
|
|
46
46
|
},
|
|
47
|
-
"contentHash": "
|
|
47
|
+
"contentHash": "c3a4b38be15260152a5244a06539639a1c7c08c207faf76d03baab43e639d148",
|
|
48
48
|
"files": [
|
|
49
49
|
{
|
|
50
50
|
"path": "components/Catalog.tsx",
|
|
@@ -68,11 +68,11 @@
|
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
"path": "components/PublishWizard.tsx",
|
|
71
|
-
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport {\n type WorkshopAccessOptionInput,\n type WorkshopDefinitionsResponse,\n type WorkshopVisibility,\n} from \"@idosgames/core\";\nimport { errorText } from \"../errors\";\nimport { useContentTypes, useResourceOptions } from \"../hooks\";\nimport { bundleLabel } from \"../labels\";\nimport {\n checkLabel,\n chip,\n chipActive,\n chipsRow,\n errorBox,\n fieldLabel,\n filtersRow,\n formCol,\n input,\n muted,\n primaryBtn,\n secondaryBtn,\n} from \"../styles\";\nimport { type WorkshopHost } from \"../types\";\n\ninterface AccessDraft {\n mode: \"Free\" | \"Price\" | \"Holding\";\n resource: string; // \"vc:GOLD\" | \"cc:USDT\" | \"item:<catalog>/<item>\"\n amount: number;\n holdMode: \"WhileHeld\" | \"UnlockOnce\";\n}\n\nexport function PublishWizard({\n host,\n defs,\n onDone,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onDone(contentID: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content).filter(\n (h) => h.listLocal && h.capture && defs.CanPublish?.[h.type] === true,\n );\n const [type, setType] = useState<string | null>(null);\n const [locals, setLocals] = useState<Array<{\n id: string;\n name: string;\n subtitle?: string;\n }> | null>(null);\n const [localId, setLocalId] = useState<string | null>(null);\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [tags, setTags] = useState(\"\");\n const [visibility, setVisibility] = useState<WorkshopVisibility>(\"Public\");\n const [access, setAccess] = useState<AccessDraft[]>([\n { mode: \"Free\", resource: \"\", amount: 1, holdMode: \"UnlockOnce\" },\n ]);\n const [busy, setBusy] = useState(false);\n const [message, setMessage] = useState<string | null>(null);\n\n const handler = handlers.find((h) => h.type === type);\n const typeDef = type ? defs.Definitions?.ContentTypes?.[type] : undefined;\n const allowedModes = typeDef?.AllowedAccessModes?.length\n ? typeDef.AllowedAccessModes\n : [\"Free\", \"Price\", \"Holding\"];\n const resources = useResourceOptions(host.client);\n // Плата за публикацию берётся только ресурсами: вариант с оплатой в сторе сервер отвергнет.\n const fee = Object.entries(typeDef?.PublishFeeOptions ?? {}).find(\n ([, option]) =>\n !(option?.Cost?.Standard?.Entries ?? []).some(\n (e) => e?.Type === \"Purchase\",\n ),\n );\n\n useEffect(() => {\n setLocals(null);\n setLocalId(null);\n if (!handler?.listLocal) return;\n void handler\n .listLocal()\n .then(setLocals, (e: unknown) =>\n setMessage(e instanceof Error ? e.message : String(e)),\n );\n }, [handler]);\n\n if (handlers.length === 0)\n return (\n <div style={muted}>\n Nothing to publish from here: the games in this project either do not\n register their content, or you can't publish it yet.\n </div>\n );\n\n const publish = async (): Promise<void> => {\n if (!handler?.capture || !type || !localId) return;\n // Опция без ресурса молча выпала бы из списка, и публикация ушла бы не с теми способами получить.\n if (access.some((a) => a.mode !== \"Free\" && !a.resource)) {\n setMessage(\"Pick a resource for every paid or hold-to-unlock option.\");\n return;\n }\n setBusy(true);\n setMessage(null);\n try {\n const captured = await handler.capture(localId);\n const r = await host.client.workshop.publish({\n contentType: type,\n files: captured.files,\n thumbnail: captured.thumbnail,\n title: title.trim() || captured.suggestedTitle || \"Untitled\",\n description: description.trim() || captured.suggestedDescription,\n tags: tags\n .split(\",\")\n .map((t) => t.trim())\n .filter(Boolean)\n .concat(captured.tags ?? []),\n metadata: captured.metadata,\n visibility,\n access: access\n .map(toAccessInput)\n .filter((a): a is WorkshopAccessOptionInput => a !== null),\n selectedFeeOptionID: fee?.[0],\n });\n if (!r.ok) setMessage(errorText(r.error));\n else if (r.data.Content?.ContentID) onDone(r.data.Content.ContentID);\n } catch (e) {\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <div style={formCol}>\n <label style={fieldLabel}>What</label>\n <div style={chipsRow}>\n {handlers.map((h) => (\n <button\n key={h.type}\n style={type === h.type ? chipActive : chip}\n onClick={() => setType(h.type)}\n >\n {h.icon} {h.label}\n </button>\n ))}\n </div>\n\n {handler && (\n <>\n <label style={fieldLabel}>Which one</label>\n {!locals ? (\n <div style={muted}>Loading…</div>\n ) : locals.length === 0 ? (\n <div style={muted}>You have nothing of this type yet.</div>\n ) : (\n <div style={formCol}>\n {locals.map((l) => (\n <label key={l.id} style={checkLabel}>\n <input\n type=\"radio\"\n name=\"wsLocal\"\n checked={localId === l.id}\n onChange={() => {\n setLocalId(l.id);\n if (!title) setTitle(l.name);\n }}\n />\n {l.name}{\" \"}\n {l.subtitle && <span style={muted}>· {l.subtitle}</span>}\n </label>\n ))}\n </div>\n )}\n </>\n )}\n\n {localId && (\n <>\n <label style={fieldLabel}>Title</label>\n <input\n style={input}\n value={title}\n maxLength={defs.Definitions?.Limits?.MaxTitleLength ?? 60}\n onChange={(e) => setTitle(e.target.value)}\n />\n <label style={fieldLabel}>Description</label>\n <textarea\n style={{ ...input, minHeight: 70 }}\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n />\n <label style={fieldLabel}>\n Tags (comma separated\n {typeDef?.TagVocabulary?.length\n ? `: ${typeDef.TagVocabulary.join(\", \")}`\n : \"\"}\n )\n </label>\n <input\n style={input}\n value={tags}\n onChange={(e) => setTags(e.target.value)}\n />\n <label style={fieldLabel}>Who can see it</label>\n <select\n style={input}\n value={visibility}\n onChange={(e) =>\n setVisibility(e.target.value as WorkshopVisibility)\n }\n >\n <option value=\"Public\">Everyone (in the catalog)</option>\n <option value=\"Unlisted\">Anyone with the link</option>\n <option value=\"Friends\">Only my friends</option>\n </select>\n\n <label style={fieldLabel}>How others get it — any option works</label>\n {access.map((a, i) => (\n <div key={i} style={filtersRow}>\n <select\n style={input}\n value={a.mode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n mode: e.target.value as AccessDraft[\"mode\"],\n }),\n )\n }\n >\n {allowedModes.map((m) => (\n <option key={m} value={m}>\n {m === \"Free\"\n ? \"Free\"\n : m === \"Price\"\n ? \"For resources\"\n : \"Hold to unlock\"}\n </option>\n ))}\n </select>\n {a.mode !== \"Free\" && (\n <>\n <select\n style={input}\n value={a.resource}\n onChange={(e) =>\n setAccess(patch(access, i, { resource: e.target.value }))\n }\n >\n <option value=\"\">— resource —</option>\n {resources\n .filter((r) =>\n // A price: an in-game currency or crypto (the author gets it minus the developer's trade\n // fee). Holding: currencies and items — crypto is not a \"hold to unlock\" resource.\n a.mode === \"Holding\"\n ? !r.startsWith(\"cc:\")\n : r.startsWith(\"vc:\") || r.startsWith(\"cc:\"),\n )\n .map((r) => (\n <option key={r} value={r}>\n {r\n .replace(/^vc:/, \"\")\n .replace(/^cc:/, \"crypto \")\n .replace(/^item:/, \"item \")}\n </option>\n ))}\n </select>\n <input\n style={{ ...input, width: 90 }}\n type=\"number\"\n min={1}\n value={a.amount}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n amount: Math.max(1, Number(e.target.value) || 1),\n }),\n )\n }\n />\n </>\n )}\n {a.mode === \"Holding\" && (\n <select\n style={input}\n value={a.holdMode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n holdMode: e.target.value as AccessDraft[\"holdMode\"],\n }),\n )\n }\n >\n <option value=\"UnlockOnce\">Checked once, then forever</option>\n <option value=\"WhileHeld\">Only while they hold it</option>\n </select>\n )}\n {access.length > 1 && (\n <button\n style={secondaryBtn}\n onClick={() => setAccess(access.filter((_, j) => j !== i))}\n >\n ✕\n </button>\n )}\n </div>\n ))}\n <button\n style={secondaryBtn}\n onClick={() =>\n setAccess([\n ...access,\n {\n mode: allowedModes.includes(\"Price\") ? \"Price\" : \"Free\",\n resource: \"\",\n amount: 10,\n holdMode: \"UnlockOnce\",\n },\n ])\n }\n >\n + Add an option\n </button>\n\n {fee && (\n <div style={muted}>\n Publishing costs {bundleLabel(fee[1].Cost?.Standard)} (charged\n once).\n </div>\n )}\n <button\n style={primaryBtn}\n disabled={busy}\n onClick={() => void publish()}\n >\n {busy ? \"Publishing…\" : \"Publish\"}\n </button>\n </>\n )}\n {message && <div style={errorBox}>{message}</div>}\n </div>\n );\n}\n\nfunction patch<T>(list: T[], index: number, change: Partial<T>): T[] {\n return list.map((item, i) => (i === index ? { ...item, ...change } : item));\n}\n\nfunction toAccessInput(a: AccessDraft): WorkshopAccessOptionInput | null {\n if (a.mode === \"Free\") return { Mode: \"Free\" };\n if (!a.resource) return null;\n const [kind, id = \"\"] = a.resource.split(\":\", 2);\n if (a.mode === \"Price\") {\n return {\n Mode: \"Price\",\n Price: {\n Entries: [\n {\n Type: kind === \"cc\" ? \"CryptoCurrency\" : \"VirtualCurrency\",\n CurrencyID: id,\n Amount: a.amount,\n },\n ],\n },\n };\n }\n const [catalogID, itemID] = id.split(\"/\", 2);\n return {\n Mode: \"Holding\",\n Holding: {\n Mode: a.holdMode,\n Match: \"All\",\n Requirements: [\n kind === \"vc\"\n ? { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount }\n : {\n Type: \"Item\",\n CatalogID: catalogID,\n ItemID: itemID ?? \"\",\n Amount: a.amount,\n },\n ],\n },\n };\n}\n\n// ────────────────────────────── Lists ──────────────────────────────\n"
|
|
71
|
+
"content": "import { kitCreditsPrompt } from \"@idosgames/react/ui\";\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport {\n type WorkshopAccessOptionInput,\n type WorkshopDefinitionsResponse,\n type WorkshopVisibility,\n} from \"@idosgames/core\";\nimport { errorText } from \"../errors\";\nimport { useContentTypes, useResourceOptions } from \"../hooks\";\nimport { bundleLabel } from \"../labels\";\nimport {\n checkLabel,\n chip,\n chipActive,\n chipsRow,\n errorBox,\n fieldLabel,\n filtersRow,\n formCol,\n input,\n muted,\n primaryBtn,\n secondaryBtn,\n} from \"../styles\";\nimport { type WorkshopHost } from \"../types\";\n\ninterface AccessDraft {\n mode: \"Free\" | \"Price\" | \"Holding\";\n resource: string; // \"vc:GOLD\" | \"cc:USDT\" | \"item:<catalog>/<item>\"\n amount: number;\n holdMode: \"WhileHeld\" | \"UnlockOnce\";\n}\n\nexport function PublishWizard({\n host,\n defs,\n onDone,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onDone(contentID: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content).filter(\n (h) => h.listLocal && h.capture && defs.CanPublish?.[h.type] === true,\n );\n const [type, setType] = useState<string | null>(null);\n const [locals, setLocals] = useState<Array<{\n id: string;\n name: string;\n subtitle?: string;\n }> | null>(null);\n const [localId, setLocalId] = useState<string | null>(null);\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [tags, setTags] = useState(\"\");\n const [visibility, setVisibility] = useState<WorkshopVisibility>(\"Public\");\n const [access, setAccess] = useState<AccessDraft[]>([\n { mode: \"Free\", resource: \"\", amount: 1, holdMode: \"UnlockOnce\" },\n ]);\n const [busy, setBusy] = useState(false);\n const [message, setMessage] = useState<string | null>(null);\n\n const handler = handlers.find((h) => h.type === type);\n const typeDef = type ? defs.Definitions?.ContentTypes?.[type] : undefined;\n const allowedModes = typeDef?.AllowedAccessModes?.length\n ? typeDef.AllowedAccessModes\n : [\"Free\", \"Price\", \"Holding\"];\n const resources = useResourceOptions(host.client);\n // Плата за публикацию берётся только ресурсами: вариант с оплатой в сторе сервер отвергнет.\n const fee = Object.entries(typeDef?.PublishFeeOptions ?? {}).find(\n ([, option]) =>\n !(option?.Cost?.Standard?.Entries ?? []).some(\n (e) => e?.Type === \"Purchase\",\n ),\n );\n\n useEffect(() => {\n setLocals(null);\n setLocalId(null);\n if (!handler?.listLocal) return;\n void handler\n .listLocal()\n .then(setLocals, (e: unknown) =>\n setMessage(e instanceof Error ? e.message : String(e)),\n );\n }, [handler]);\n\n if (handlers.length === 0)\n return (\n <div style={muted}>\n Nothing to publish from here: the games in this project either do not\n register their content, or you can't publish it yet.\n </div>\n );\n\n const publish = async (): Promise<void> => {\n if (!handler?.capture || !type || !localId) return;\n // Опция без ресурса молча выпала бы из списка, и публикация ушла бы не с теми способами получить.\n if (access.some((a) => a.mode !== \"Free\" && !a.resource)) {\n setMessage(\"Pick a resource for every paid or hold-to-unlock option.\");\n return;\n }\n setBusy(true);\n setMessage(null);\n // A publish fee in Aspis: the player confirms it on idosgames.com (the kit shows it); the upload is done once.\n const credits = kitCreditsPrompt(host.client);\n try {\n const captured = await handler.capture(localId);\n const r = await host.client.workshop.publish({\n contentType: type,\n files: captured.files,\n thumbnail: captured.thumbnail,\n title: title.trim() || captured.suggestedTitle || \"Untitled\",\n description: description.trim() || captured.suggestedDescription,\n tags: tags\n .split(\",\")\n .map((t) => t.trim())\n .filter(Boolean)\n .concat(captured.tags ?? []),\n metadata: captured.metadata,\n visibility,\n access: access\n .map(toAccessInput)\n .filter((a): a is WorkshopAccessOptionInput => a !== null),\n selectedFeeOptionID: fee?.[0],\n credits: credits.options,\n });\n await credits.done(r);\n if (!r.ok) setMessage(errorText(r.error));\n else if (r.data.Content?.ContentID) onDone(r.data.Content.ContentID);\n } catch (e) {\n await credits.done({ ok: true, data: null });\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <div style={formCol}>\n <label style={fieldLabel}>What</label>\n <div style={chipsRow}>\n {handlers.map((h) => (\n <button\n key={h.type}\n style={type === h.type ? chipActive : chip}\n onClick={() => setType(h.type)}\n >\n {h.icon} {h.label}\n </button>\n ))}\n </div>\n\n {handler && (\n <>\n <label style={fieldLabel}>Which one</label>\n {!locals ? (\n <div style={muted}>Loading…</div>\n ) : locals.length === 0 ? (\n <div style={muted}>You have nothing of this type yet.</div>\n ) : (\n <div style={formCol}>\n {locals.map((l) => (\n <label key={l.id} style={checkLabel}>\n <input\n type=\"radio\"\n name=\"wsLocal\"\n checked={localId === l.id}\n onChange={() => {\n setLocalId(l.id);\n if (!title) setTitle(l.name);\n }}\n />\n {l.name}{\" \"}\n {l.subtitle && <span style={muted}>· {l.subtitle}</span>}\n </label>\n ))}\n </div>\n )}\n </>\n )}\n\n {localId && (\n <>\n <label style={fieldLabel}>Title</label>\n <input\n style={input}\n value={title}\n maxLength={defs.Definitions?.Limits?.MaxTitleLength ?? 60}\n onChange={(e) => setTitle(e.target.value)}\n />\n <label style={fieldLabel}>Description</label>\n <textarea\n style={{ ...input, minHeight: 70 }}\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n />\n <label style={fieldLabel}>\n Tags (comma separated\n {typeDef?.TagVocabulary?.length\n ? `: ${typeDef.TagVocabulary.join(\", \")}`\n : \"\"}\n )\n </label>\n <input\n style={input}\n value={tags}\n onChange={(e) => setTags(e.target.value)}\n />\n <label style={fieldLabel}>Who can see it</label>\n <select\n style={input}\n value={visibility}\n onChange={(e) =>\n setVisibility(e.target.value as WorkshopVisibility)\n }\n >\n <option value=\"Public\">Everyone (in the catalog)</option>\n <option value=\"Unlisted\">Anyone with the link</option>\n <option value=\"Friends\">Only my friends</option>\n </select>\n\n <label style={fieldLabel}>How others get it — any option works</label>\n {access.map((a, i) => (\n <div key={i} style={filtersRow}>\n <select\n style={input}\n value={a.mode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n mode: e.target.value as AccessDraft[\"mode\"],\n }),\n )\n }\n >\n {allowedModes.map((m) => (\n <option key={m} value={m}>\n {m === \"Free\"\n ? \"Free\"\n : m === \"Price\"\n ? \"For resources\"\n : \"Hold to unlock\"}\n </option>\n ))}\n </select>\n {a.mode !== \"Free\" && (\n <>\n <select\n style={input}\n value={a.resource}\n onChange={(e) =>\n setAccess(patch(access, i, { resource: e.target.value }))\n }\n >\n <option value=\"\">— resource —</option>\n {resources\n .filter((r) =>\n // A price: an in-game currency or crypto (the author gets it minus the developer's trade\n // fee). Holding: currencies and items — crypto is not a \"hold to unlock\" resource.\n a.mode === \"Holding\"\n ? !r.startsWith(\"cc:\")\n : r.startsWith(\"vc:\") || r.startsWith(\"cc:\"),\n )\n .map((r) => (\n <option key={r} value={r}>\n {r\n .replace(/^vc:/, \"\")\n .replace(/^cc:/, \"crypto \")\n .replace(/^item:/, \"item \")}\n </option>\n ))}\n </select>\n <input\n style={{ ...input, width: 90 }}\n type=\"number\"\n min={1}\n value={a.amount}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n amount: Math.max(1, Number(e.target.value) || 1),\n }),\n )\n }\n />\n </>\n )}\n {a.mode === \"Holding\" && (\n <select\n style={input}\n value={a.holdMode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n holdMode: e.target.value as AccessDraft[\"holdMode\"],\n }),\n )\n }\n >\n <option value=\"UnlockOnce\">Checked once, then forever</option>\n <option value=\"WhileHeld\">Only while they hold it</option>\n </select>\n )}\n {access.length > 1 && (\n <button\n style={secondaryBtn}\n onClick={() => setAccess(access.filter((_, j) => j !== i))}\n >\n ✕\n </button>\n )}\n </div>\n ))}\n <button\n style={secondaryBtn}\n onClick={() =>\n setAccess([\n ...access,\n {\n mode: allowedModes.includes(\"Price\") ? \"Price\" : \"Free\",\n resource: \"\",\n amount: 10,\n holdMode: \"UnlockOnce\",\n },\n ])\n }\n >\n + Add an option\n </button>\n\n {fee && (\n <div style={muted}>\n Publishing costs {bundleLabel(fee[1].Cost?.Standard)} (charged\n once).\n </div>\n )}\n <button\n style={primaryBtn}\n disabled={busy}\n onClick={() => void publish()}\n >\n {busy ? \"Publishing…\" : \"Publish\"}\n </button>\n </>\n )}\n {message && <div style={errorBox}>{message}</div>}\n </div>\n );\n}\n\nfunction patch<T>(list: T[], index: number, change: Partial<T>): T[] {\n return list.map((item, i) => (i === index ? { ...item, ...change } : item));\n}\n\nfunction toAccessInput(a: AccessDraft): WorkshopAccessOptionInput | null {\n if (a.mode === \"Free\") return { Mode: \"Free\" };\n if (!a.resource) return null;\n const [kind, id = \"\"] = a.resource.split(\":\", 2);\n if (a.mode === \"Price\") {\n return {\n Mode: \"Price\",\n Price: {\n Entries: [\n {\n Type: kind === \"cc\" ? \"CryptoCurrency\" : \"VirtualCurrency\",\n CurrencyID: id,\n Amount: a.amount,\n },\n ],\n },\n };\n }\n const [catalogID, itemID] = id.split(\"/\", 2);\n return {\n Mode: \"Holding\",\n Holding: {\n Mode: a.holdMode,\n Match: \"All\",\n Requirements: [\n kind === \"vc\"\n ? { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount }\n : {\n Type: \"Item\",\n CatalogID: catalogID,\n ItemID: itemID ?? \"\",\n Amount: a.amount,\n },\n ],\n },\n };\n}\n\n// ────────────────────────────── Lists ──────────────────────────────\n"
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
74
|
"path": "errors.ts",
|
|
75
|
-
"content": "/** Server error codes → words. Anything else is shown as is (it is already a sentence). */\nconst ERRORS: Record<string, string> = {\n WORKSHOP_DISABLED: \"The Workshop is turned off.\",\n WORKSHOP_NOT_CONFIGURED: \"The Workshop is not set up in this game yet.\",\n WORKSHOP_NOT_AVAILABLE: \"The Workshop is not available to you.\",\n WORKSHOP_CLOSED: \"The Workshop is closed right now.\",\n PRICE_CHANGED: \"The author changed the price — look at it again.\",\n HOLDING_REQUIREMENTS_NOT_MET: \"You don't hold what this option requires.\",\n ACCESS_DENIED: \"You don't have access to this yet.\",\n CONTENT_NOT_FOUND: \"Not found (or not shared with you).\",\n CONTENT_NOT_AVAILABLE: \"This publication is not available now.\",\n CONTENT_TEXT_REJECTED:\n \"The title or description contains words that are not allowed.\",\n PUBLISH_LIMIT_REACHED:\n \"You have reached the limit of publications of this type.\",\n DAILY_PUBLISH_LIMIT_REACHED: \"You have reached today's publishing limit.\",\n PUBLISH_NOT_ALLOWED: \"You can't publish this type of content yet.\",\n CANNOT_LIKE_OWN_CONTENT: \"You can't like your own publication.\",\n WORKSHOP_STORAGE_NOT_CONFIGURED:\n \"File storage is not configured for this game.\",\n TOO_MANY_PENDING_UPLOADS:\n \"You have too many unfinished uploads — try again a bit later.\",\n ACCESS_REQUIRED: \"Add at least one way for others to get it.\",\n OPTION_REQUIRED: \"Choose how you want to get it.\",\n PRICE_NO_LONGER_ALLOWED: \"This price is no longer allowed in this game.\",\n TITLE_REQUIRED: \"Give it a title.\",\n TITLE_TOO_LONG: \"The title is too long.\",\n THUMBNAIL_REQUIRED: \"This type of content needs a preview image.\",\n FILE_TOO_LARGE: \"The file is too large for this type of content.\",\n FILE_TYPE_MISMATCH: \"The file does not match its declared format.\",\n CONTENT_TYPE_NOT_ALLOWED:\n \"This type of content can't be published in this game.\",\n CANNOT_REPORT_OWN_CONTENT: \"You can't report your own publication.\",\n CANNOT_FOLLOW_SELF: \"You can't follow yourself.\",\n};\n\nexport const errorText = (e: string): string
|
|
75
|
+
"content": "import { errorText as kitErrorText } from \"@idosgames/react/ui\";\n\n/** Server error codes → words. Anything else is shown as is (it is already a sentence). */\nconst ERRORS: Record<string, string> = {\n WORKSHOP_DISABLED: \"The Workshop is turned off.\",\n WORKSHOP_NOT_CONFIGURED: \"The Workshop is not set up in this game yet.\",\n WORKSHOP_NOT_AVAILABLE: \"The Workshop is not available to you.\",\n WORKSHOP_CLOSED: \"The Workshop is closed right now.\",\n PRICE_CHANGED: \"The author changed the price — look at it again.\",\n HOLDING_REQUIREMENTS_NOT_MET: \"You don't hold what this option requires.\",\n ACCESS_DENIED: \"You don't have access to this yet.\",\n CONTENT_NOT_FOUND: \"Not found (or not shared with you).\",\n CONTENT_NOT_AVAILABLE: \"This publication is not available now.\",\n CONTENT_TEXT_REJECTED:\n \"The title or description contains words that are not allowed.\",\n PUBLISH_LIMIT_REACHED:\n \"You have reached the limit of publications of this type.\",\n DAILY_PUBLISH_LIMIT_REACHED: \"You have reached today's publishing limit.\",\n PUBLISH_NOT_ALLOWED: \"You can't publish this type of content yet.\",\n CANNOT_LIKE_OWN_CONTENT: \"You can't like your own publication.\",\n WORKSHOP_STORAGE_NOT_CONFIGURED:\n \"File storage is not configured for this game.\",\n TOO_MANY_PENDING_UPLOADS:\n \"You have too many unfinished uploads — try again a bit later.\",\n ACCESS_REQUIRED: \"Add at least one way for others to get it.\",\n OPTION_REQUIRED: \"Choose how you want to get it.\",\n PRICE_NO_LONGER_ALLOWED: \"This price is no longer allowed in this game.\",\n TITLE_REQUIRED: \"Give it a title.\",\n TITLE_TOO_LONG: \"The title is too long.\",\n THUMBNAIL_REQUIRED: \"This type of content needs a preview image.\",\n FILE_TOO_LARGE: \"The file is too large for this type of content.\",\n FILE_TYPE_MISMATCH: \"The file does not match its declared format.\",\n CONTENT_TYPE_NOT_ALLOWED:\n \"This type of content can't be published in this game.\",\n CANNOT_REPORT_OWN_CONTENT: \"You can't report your own publication.\",\n CANNOT_FOLLOW_SELF: \"You can't follow yourself.\",\n};\n\n/** Aspis (publish fee) refusals are worded by the kit, like in every other system. */\nconst isCredits = (e: string): boolean =>\n e.startsWith(\"CREDITS_\") || e === \"NOT_ENOUGH_CREDITS\";\n\nexport const errorText = (e: string): string =>\n ERRORS[e] ?? (isCredits(e) ? kitErrorText(e) : e);\n"
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
"path": "hooks.ts",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-generation-system",
|
|
3
3
|
"description": "Add InApp AI generation to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text generation (optionally with images in — e.g. build a voxel world from a picture the player uploads), image generation and image editing, video, speech (TTS), music and 3D models — every generation is a job the caller waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at runtime in a game built on the iDosGames TS SDK or its templates (board-game, idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels / worlds, voice lines, generated music, 3D props — or touches client.ai, AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature, AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode, isAIGenerationTerminal or parseAIErrorCode — even if they don't name the module.",
|
|
4
|
-
"content": "---\nname: ai-generation-system\ndescription: >-\n Add InApp AI generation to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text\n generation (optionally with images in — e.g. build a voxel world from a\n picture the player uploads), image generation and image editing, video,\n speech (TTS), music and 3D models — every generation is a job the caller\n waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at\n runtime in a game built on the iDosGames TS SDK or its templates (board-game,\n idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels /\n worlds, voice lines, generated music, 3D props — or touches client.ai,\n AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature,\n AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode,\n isAIGenerationTerminal or parseAIErrorCode — even if they don't name the\n module.\n---\n\n# AI generation system (iDosGames TS SDK)\n\nThe AI module lets a **player** generate content at runtime — text, images,\nvideo, speech, music, 3D — through the game backend. The title's publisher\ndefines **features** (each with a stable `FeatureKey` such as `\"npc-chat\"`,\n`\"voxel-world\"`, `\"skin-gen\"`) in the dashboard or through the backend\ntitle-config MCP (`save_ai`). A feature fixes the modality, the model, the\nsystem prompt, the limits and the in-game price for the player. The game only\nnames the feature and sends the player's input.\n\nIt is **server-authoritative and publisher-paid**:\n\n- The client **never** picks a model, a prompt or a price. There is no such\n field on the wire, and there must not be: generation runs on the publisher's\n credits, so a client that chose the model would choose the publisher's bill.\n- The provider cost is charged to the **publisher's** credits. On top of that\n the **player** may pay in-game resources (the feature's `PriceOptions`) —\n returned on the generation as `Charge` and applied to the local cache by the\n SDK.\n- Failures after a charge are refunded in full by the server\n (`PlayerRefunded: true` on the generation).\n\nThis skill is for **using** `client.ai`. Creating or changing features is\npublisher configuration (dashboard / `save_ai`); the client cannot do it.\n\n## Key data entities\n\n1. **`AIPublicDefinitions`** (per title) — the sanitized feature list from\n `getDefinitions()`: for each `FeatureKey`, its `Modality`, `Limits`,\n `PriceOptions`, what the client may send\n (`AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes`,\n `MaxImages`, `AllowEdit`, `AllowReferenceImage`, `MaxPromptChars`,\n `LockBehavior`, `HistoryMessages`, `MaxTokens`) and UI defaults. No prompt,\n no model. Disabled features are simply absent. Cached as config section\n `\"AI\"` — it is **not** part of the title config, you must call\n `getDefinitions()`.\n2. **`AIGenerationView`** — one generation as the player sees it:\n `GenerationID`, `FeatureKey`, `Modality`, `Status`\n (`\"pending\" | \"running\" | \"completed\" | \"failed\"`), `Progress` (0..100, async\n jobs), `OutputText`, `Assets[]` (`{ Url, ContentType, Variant }`),\n `Error { Code, Message }`, `Charge`, `PlayerRefunded`, `CreatedAt`,\n `UpdatedAt`.\n3. **No player-state slot.** Limit counters live on the server; history is\n `listGenerations()` / `getGeneration()`.\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 ai = client.ai; // the AIService\nconst defs = await ai.getDefinitions();\n```\n\nEvery method needs a session; without one it returns\n`{ ok: false, reason: \"unauthorized\" }` — it never throws.\n\n## Methods\n\nAll return `Promise<OperationResult<T>>`. The `options` argument of every\ngenerate method is `{ relatedEntityID?, selectedOptionID? }` (see\nIdempotency).\n\n| Method | Purpose | `data` |\n| --------------------------------------------- | --------------------------------------------------------------- | ---------------------- |\n| `getDefinitions()` | The title's features (sanitized). | `AIPublicDefinitions` |\n| `generateText(featureKey, input, options?)` | Submit a text job → `pending`. | `AIGenerationView` |\n| `getText(generationID)` | Poll a text job. | `AIGenerationView` |\n| `generateImage(featureKey, input, options?)` | Submit an image job → `pending`. | `AIGenerationView` |\n| `editImage(featureKey, input, options?)` | Submit an image edit (feature with `AllowEdit`) → `pending`. | `AIGenerationView` |\n| `getImage(generationID)` | Poll an image / edit job. | `AIGenerationView` |\n| `generateVideo(featureKey, input, options?)` | Submit a video job → `running`. | `AIGenerationView` |\n| `getVideo(generationID)` | Poll a video job. | `AIGenerationView` |\n| `generateAudio(featureKey, input, options?)` | Submit a speech (TTS) job → `pending`. | `AIGenerationView` |\n| `getAudio(generationID)` | Poll a speech job. | `AIGenerationView` |\n| `generateMusic(featureKey, input, options?)` | Submit a music job → `pending`. | `AIGenerationView` |\n| `getMusic(generationID)` | Poll a music job. | `AIGenerationView` |\n| `generateThreeD(featureKey, input, options?)` | Submit a 3D job → `running`. | `AIGenerationView` |\n| `getThreeD(generationID)` | Poll a 3D job. | `AIGenerationView` |\n| `waitForGeneration(view or id, options?)` | Poll the job with its own action until it is final. | `AIGenerationView` |\n| `listGenerations(query?)` | History page (`{ Modality, FeatureKey, Status, Skip, Limit }`). | `AIGenerationListView` |\n| `getGeneration(generationID)` | One generation (history). | `AIGenerationView` |\n| `isTerminal(status)` | `true` once polling can stop. | `boolean` |\n\n**Every generation is a job** (since 13.09.2026). A generate call only submits\nit and answers `pending` (text, image, edit, speech, music — queued on the\nserver) or `running` (video, 3D — submitted to the provider); the server never\nwaits for the model inside the request. Get the result with\n`waitForGeneration(view)` — it polls the right action until the status is\nfinal and returns the final view.\n\n### Two different kinds of failure — handle both\n\n```ts\nimport { parseAIErrorCode, AIErrorCode } from \"@idosgames/core\";\n\nconst res = await client.ai.generateImage(\"skin-gen\", { Prompt: text });\n\nif (!res.ok) {\n // 1. The REQUEST was refused (nothing generated, nothing charged) — or a\n // transport problem. Server refusals arrive as \"AI_CODE: safe message\".\n const code = parseAIErrorCode(res.error);\n if (code === AIErrorCode.LimitReached)\n return showToast(\"Come back tomorrow!\");\n if (code === AIErrorCode.Unavailable)\n return showToast(\"Not available right now.\");\n if (res.reason === \"connection\") return offerRetry(); // see Idempotency\n return showToast(res.error);\n}\n\nconst done = await client.ai.waitForGeneration(res.data);\nif (!done.ok) return showToast(\"Still working — check back later.\"); // timed out / aborted; the job keeps running\n\nif (done.data.Status === \"failed\") {\n // 2. The generation RAN and failed. The calls succeeded; PlayerRefunded says\n // the player got the price back. Show Error.Message, branch on Error.Code.\n return showToast(done.data.Error?.Message ?? \"Generation failed.\");\n}\n\nshowImage(done.data.Assets?.[0]?.Url);\n```\n\n`reason` is one of `\"client\"` (missing `featureKey` / `generationID`),\n`\"unauthorized\"`, `\"throttled\"`, `\"connection\"`, `\"validation\"`, `\"server\"`.\n\n## Recipes\n\n### AI NPC dialogue (text with history)\n\n```ts\nconst history: AIChatMessage[] = [];\n\nasync function say(playerLine: string) {\n const res = await client.ai.generateText(\"npc-chat\", {\n Prompt: playerLine,\n Messages: history, // earlier turns; the server keeps the feature's HistoryMessages last ones\n });\n if (!res.ok) return null;\n const done = await client.ai.waitForGeneration(res.data, {\n intervalMs: 1000,\n });\n if (!done.ok || done.data.Status !== \"completed\") return null;\n\n history.push({ Role: \"user\", Content: playerLine });\n history.push({ Role: \"assistant\", Content: done.data.OutputText ?? \"\" });\n return done.data.OutputText;\n}\n```\n\nThe NPC's persona is the feature's system prompt on the server — don't put it\nin `Prompt`. A reply takes a few seconds: show a \"thinking…\" state and disable\nthe send button. `intervalMs: 1000` shows a short reply quickly; the wait slows\ndown on its own for a long answer.\n\n### Picture in, text out — build a voxel world from a picture\n\nFor a Text feature with `AllowImages`, attach images to the prompt. Check the\nfeature's constraints before uploading so the player gets an instant message\ninstead of a round trip:\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"voxel-world\"] : undefined;\n\nasync function fileToInput(file: File): Promise<AIInputImage | string> {\n if (!feature?.AllowImages) return \"This feature does not take pictures.\";\n const types = feature.AllowedImageTypes ?? [\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n ];\n if (!types.includes(file.type)) return \"Use a PNG, JPEG or WebP picture.\";\n if (feature.MaxInputImageBytes && file.size > feature.MaxInputImageBytes)\n return \"The picture is too large.\";\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.onerror = () => reject(r.error);\n r.readAsDataURL(file);\n });\n return { Base64: dataUrl, ContentType: file.type }; // a data-URL is accepted as is\n}\n\nconst image = await fileToInput(file);\nif (typeof image === \"string\") return showToast(image);\n\nconst res = await client.ai.generateText(\"voxel-world\", {\n Prompt: \"Turn this picture into a voxel world. Answer with JSON only.\",\n Images: [image],\n});\nif (!res.ok) return showToast(res.error);\n\n// A whole world is a long answer (minutes): wait with progress, stop when the screen closes.\nconst done = await client.ai.waitForGeneration(res.data, {\n signal: screenAbort.signal,\n});\n\nconst world = parseWorldJson(done.ok ? done.data.OutputText : null); // validate it — it is model output\n```\n\nThe expected output format (\"JSON with blocks: [...]\") belongs in the feature's\nsystem prompt, so every player gets the same contract. Always validate the\nparsed structure: it is model output, not trusted data.\n\n### Generate an image, then edit it\n\n```ts\nconst img = await client.ai.generateImage(\"skin-gen\", {\n Prompt: \"a red dragon armour skin\",\n Size: \"1024x1024\", // ignored if the feature has LockBehavior\n});\nif (!img.ok) return;\nconst made = await client.ai.waitForGeneration(img.data);\nif (!made.ok || made.data.Status !== \"completed\") return;\nconst url = made.data.Assets?.[0]?.Url;\n\n// Feed a generated asset back in by URL — only assets THIS title generated are accepted.\nconst edit = await client.ai.editImage(\"skin-gen\", {\n Prompt: \"make it blue\",\n Images: [{ AssetUrl: url }],\n});\nconst edited = edit.ok ? await client.ai.waitForGeneration(edit.data) : edit;\n```\n\n### Waiting for a job — `waitForGeneration`\n\nNothing polls on its own (platform convention, as with chat): each poll is an\nAPI call billed to the publisher, so wait only while the player needs the\nresult.\n\n```ts\nconst job = await client.ai.generateVideo(\"trailer\", {\n Prompt: \"a dragon over a castle\",\n Seconds: 8,\n});\nif (job.ok) {\n const done = await client.ai.waitForGeneration(job.data, {\n signal: screenAbort.signal, // stop when the screen closes\n onUpdate: (v) => renderProgress(v.Progress), // 0..100 when the provider reports it\n });\n if (done.ok && done.data.Status === \"completed\")\n playVideo(done.data.Assets?.[0]?.Url);\n}\n```\n\n- Options: `intervalMs` (first pause, default 2000; grows ×1.5 up to\n `maxIntervalMs`, default 5000), `timeoutMs` (default 30 minutes), `signal`,\n `onUpdate`. A dropped connection does not end the wait; a timeout or the\n signal ends it with `reason: \"client\"` — the job keeps running on the server.\n- An abandoned job is not lost: save its `GenerationID` and resume later with\n `waitForGeneration(id)` (it reads the generation once to learn its modality),\n or find it in `listGenerations()`.\n- Polling by hand: use the action of the modality (`getText`, `getImage`,\n `getAudio`, `getMusic`, `getVideo`, `getThreeD`) — only it moves a provider\n job forward. `isTerminal` treats anything other than `pending` / `running`\n as final, so a status added later never makes a loop spin forever.\n- 3D `Assets` may hold several files (e.g. a model and a preview) — pick by\n `Variant` / `ContentType`.\n\n### Speech and music\n\n```ts\nconst voice = await client.ai.generateAudio(\"npc-voice\", {\n Text: \"Welcome, traveller!\",\n});\nconst spoken = voice.ok ? await client.ai.waitForGeneration(voice.data) : voice;\nif (spoken.ok && spoken.data.Status === \"completed\")\n new Audio(spoken.data.Assets?.[0]?.Url).play();\n\nconst track = await client.ai.generateMusic(\"level-music\", {\n Prompt: \"calm lofi for a farm level\",\n});\nconst music = track.ok ? await client.ai.waitForGeneration(track.data) : track;\n```\n\n### Idempotency and retries\n\nEach generate call sends a `RelatedEntityID`. Without `options` the SDK mints\na fresh one per call, so **two calls are two paid generations**. A retry with\nthe **same** key returns the original generation and is never charged twice.\n\nGenerate calls use a long timeout and **no automatic transport retry** (a\nresend could reach the server while the first attempt is still running). A\nnetwork failure therefore surfaces as `reason: \"connection\"`. To retry\nsafely, own the key:\n\n```ts\nconst key = crypto.randomUUID();\nlet res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n});\nif (!res.ok && res.reason === \"connection\")\n res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n }); // same generation\n```\n\nA key must be 1–128 characters of `A–Z a–z 0–9 _ -` (a UUID fits). Anything\nelse fails locally with `reason: \"client\"`: the server would strip the other\ncharacters and cut at 128, and two different keys could silently become one\ngeneration. Don't add the feature or player to the key — the server already\nscopes keys by title and player.\n\n### Pay with a specific option\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"skin-gen\"] : undefined;\n// feature.PriceOptions: Record<OptionID, PriceOption> — empty/absent = free for the player.\nawait client.ai.generateImage(\"skin-gen\", input, { selectedOptionID: \"gems\" });\n```\n\nOmit `selectedOptionID` to pay with the first available option. Filter options\nby platform with `client.checkout` before showing them, like any price.\n\n### Pay with a store purchase (IAP)\n\nAn option may be a store purchase (a `Purchase` entry): **one purchase pays for\nexactly one generation.** Buy the consumable in the native store, keep the\ntransaction PENDING, and send its receipt:\n\n```ts\nconst res = await client.ai.generateImage(\"skin-gen\", input, {\n selectedOptionID: \"store\",\n payment: { Store: \"GooglePlay\", Receipt: receipt },\n});\nconst done = res.ok ? await client.ai.waitForGeneration(res.data) : res;\nif (done.ok && done.data.PaymentSettled === true) finishStoreTransaction(); // consume now\n```\n\n- `PaymentSettled: true` — the generation is ready, the purchase is counted:\n finish (consume) the store transaction.\n- `PaymentSettled: false` on a job still in progress (every generation until it\n completes) — keep the transaction pending and finish it when a poll reports `true`.\n- `PaymentSettled: false` on a **failed** generation — keep the transaction and\n retry with the SAME receipt and a NEW `relatedEntityID`: the retry is free.\n- Re-sending a receipt that already paid returns that generation — it never\n pays for a second one. A publisher out of credits refuses BEFORE the receipt\n is taken, so the purchase is not burnt.\n\n### History\n\n```ts\nconst page = await client.ai.listGenerations({\n Modality: \"Image\",\n Status: \"completed\",\n Limit: 20,\n});\nconst one = await client.ai.getGeneration(generationID);\n```\n\nHistory reads never change the cache and emit no events.\n\n## Events\n\nSubscribe with `client.on(...)` (returns an unsubscribe fn):\n\n- `ai:definitionsLoaded` → `AIPublicDefinitions`\n- `ai:generationUpdated` → `AIGenerationView` — every generate and poll response\n- `ai:generationCompleted` → `AIGenerationView` — a generate/poll came back `completed`\n- `ai:generationFailed` → `AIGenerationView` — a generate/poll came back `failed`\n\nApplying a `Charge` also fires the usual resource events\n(`user:virtualCurrencyUpdated`, `user:inventoryUpdated`,\n`user:eventTokenUpdated`, `user:anyUpdated`).\n\n## Error codes\n\n`AIErrorCode` constants; a refusal's `error` reads `\"CODE: message\"`\n(`parseAIErrorCode`), a failed generation carries `Error.Code`.\n\n| Code | Meaning | What to show |\n| ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ |\n| `AI_DISABLED` | AI is off for the title | hide AI UI |\n| `AI_FEATURE_NOT_FOUND` / `AI_FEATURE_DISABLED` | no such / disabled feature | hide the entry point; refresh `getDefinitions()` |\n| `AI_WRONG_ACTION` | action ≠ feature modality | a bug in the game: fix the call |\n| `AI_NOT_AVAILABLE_FOR_PLAYER` | the feature's audience gate excludes this player | hide the entry point |\n| `AI_FEATURE_CLOSED` | outside the feature's schedule | \"not available now\" |\n| `AI_LIMIT_REACHED` / `AI_COOLDOWN` | the feature's `Limits` | \"come back later\" |\n| `AI_BAD_INPUT` | too long, too many / too large / wrong images, a foreign `AssetUrl` | the message |\n| `AI_PROMPT_REJECTED` | blocked terms | ask to rephrase |\n| `AI_UNAVAILABLE` | the publisher's side (credits, plan, caps) — reason deliberately hidden | a neutral \"not available right now\" |\n| `AI_GENERATION_FAILED` | the provider failed; everything charged is returned | \"try again\" |\n| `AI_GENERATION_NOT_FOUND` | unknown `GenerationID` | drop the stored id |\n\nTreat any other `AI_…` code generically — newer backends may add codes.\n\n## Gotchas\n\n- **A generate response is not the result.** It is `pending` / `running`; wait\n with `waitForGeneration`, then check the final `Status` — a failed generation\n is a successful call (`result.ok` ≠ \"generated\").\n- **One generate per action at a time.** A second `generateImage` while one is\n in flight is rejected with `reason: \"throttled\"` (the server serializes\n them per player and action anyway). Disable the button while waiting. Polls\n of _different_ jobs are not throttled against each other.\n- **The Charge is handled for you.** It is applied once, from the generate\n response. A generation with `PlayerRefunded: true` (failed after charging;\n the server returned the price) is never applied — its `Charge` still shows\n what was taken — and if this session had already applied it (every job is\n charged at submit), the SDK re-reads the inventory before emitting the\n event. A failed generation WITHOUT `PlayerRefunded` keeps its charge. Don't\n apply `Charge` yourself.\n- **Behaviour overrides are optional and may be ignored.** Size, quality,\n voice, image count… apply only when the feature has no `LockBehavior`;\n `MaxTokens` can only _lower_ the feature's value; `MaxPromptChars` counts the\n history too.\n- **Input images are checked by file signature**, not by the declared\n `ContentType`; `AssetUrl` must point to an asset this title generated.\n- **Don't cache `Assets[].Url` forever as the only copy** if the player owns\n the result — store the `GenerationID` and re-read it when needed.\n- **Unknown modality on an old SDK** falls back to `\"Text\"` when parsing\n definitions; the server still enforces the real one (`AI_WRONG_ACTION`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every request/response\nfield, the feature fields and their defaults (no platform caps — only the\nmodel's own limits), the server-side order of checks,\nand how charges, refunds and async jobs behave.\n",
|
|
4
|
+
"content": "---\nname: ai-generation-system\ndescription: >-\n Add InApp AI generation to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text\n generation (optionally with images in — e.g. build a voxel world from a\n picture the player uploads), image generation and image editing, video,\n speech (TTS), music and 3D models — every generation is a job the caller\n waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at\n runtime in a game built on the iDosGames TS SDK or its templates (board-game,\n idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels /\n worlds, voice lines, generated music, 3D props — or touches client.ai,\n AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature,\n AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode,\n isAIGenerationTerminal or parseAIErrorCode — even if they don't name the\n module.\n---\n\n# AI generation system (iDosGames TS SDK)\n\nThe AI module lets a **player** generate content at runtime — text, images,\nvideo, speech, music, 3D — through the game backend. The title's publisher\ndefines **features** (each with a stable `FeatureKey` such as `\"npc-chat\"`,\n`\"voxel-world\"`, `\"skin-gen\"`) in the dashboard or through the backend\ntitle-config MCP (`save_ai`). A feature fixes the modality, the model, the\nsystem prompt, the limits and the in-game price for the player. The game only\nnames the feature and sends the player's input.\n\nIt is **server-authoritative and publisher-paid**:\n\n- The client **never** picks a model, a prompt or a price. There is no such\n field on the wire, and there must not be: generation runs on the publisher's\n credits, so a client that chose the model would choose the publisher's bill.\n- The provider cost is charged to the **publisher's** credits. On top of that\n the **player** may pay in-game resources (the feature's `PriceOptions`) —\n returned on the generation as `Charge` and applied to the local cache by the\n SDK.\n- Failures after a charge are refunded in full by the server\n (`PlayerRefunded: true` on the generation).\n\nThis skill is for **using** `client.ai`. Creating or changing features is\npublisher configuration (dashboard / `save_ai`); the client cannot do it.\n\n## Key data entities\n\n1. **`AIPublicDefinitions`** (per title) — the sanitized feature list from\n `getDefinitions()`: for each `FeatureKey`, its `Modality`, `Limits`,\n `PriceOptions`, what the client may send\n (`AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes`,\n `MaxImages`, `AllowEdit`, `AllowReferenceImage`, `MaxPromptChars`,\n `LockBehavior`, `HistoryMessages`, `MaxTokens`) and UI defaults. No prompt,\n no model. Disabled features are simply absent. Cached as config section\n `\"AI\"` — it is **not** part of the title config, you must call\n `getDefinitions()`.\n2. **`AIGenerationView`** — one generation as the player sees it:\n `GenerationID`, `FeatureKey`, `Modality`, `Status`\n (`\"pending\" | \"running\" | \"completed\" | \"failed\"`), `Progress` (0..100, async\n jobs), `OutputText`, `Assets[]` (`{ Url, ContentType, Variant }`),\n `Error { Code, Message }`, `Charge`, `PlayerRefunded`, `CreatedAt`,\n `UpdatedAt`.\n3. **No player-state slot.** Limit counters live on the server; history is\n `listGenerations()` / `getGeneration()`.\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 ai = client.ai; // the AIService\nconst defs = await ai.getDefinitions();\n```\n\nEvery method needs a session; without one it returns\n`{ ok: false, reason: \"unauthorized\" }` — it never throws.\n\n## Methods\n\nAll return `Promise<OperationResult<T>>`. The `options` argument of every\ngenerate method is `{ relatedEntityID?, selectedOptionID? }` (see\nIdempotency).\n\n| Method | Purpose | `data` |\n| --------------------------------------------- | --------------------------------------------------------------- | ---------------------- |\n| `getDefinitions()` | The title's features (sanitized). | `AIPublicDefinitions` |\n| `generateText(featureKey, input, options?)` | Submit a text job → `pending`. | `AIGenerationView` |\n| `getText(generationID)` | Poll a text job. | `AIGenerationView` |\n| `generateImage(featureKey, input, options?)` | Submit an image job → `pending`. | `AIGenerationView` |\n| `editImage(featureKey, input, options?)` | Submit an image edit (feature with `AllowEdit`) → `pending`. | `AIGenerationView` |\n| `getImage(generationID)` | Poll an image / edit job. | `AIGenerationView` |\n| `generateVideo(featureKey, input, options?)` | Submit a video job → `running`. | `AIGenerationView` |\n| `getVideo(generationID)` | Poll a video job. | `AIGenerationView` |\n| `generateAudio(featureKey, input, options?)` | Submit a speech (TTS) job → `pending`. | `AIGenerationView` |\n| `getAudio(generationID)` | Poll a speech job. | `AIGenerationView` |\n| `generateMusic(featureKey, input, options?)` | Submit a music job → `pending`. | `AIGenerationView` |\n| `getMusic(generationID)` | Poll a music job. | `AIGenerationView` |\n| `generateThreeD(featureKey, input, options?)` | Submit a 3D job → `running`. | `AIGenerationView` |\n| `getThreeD(generationID)` | Poll a 3D job. | `AIGenerationView` |\n| `waitForGeneration(view or id, options?)` | Poll the job with its own action until it is final. | `AIGenerationView` |\n| `listGenerations(query?)` | History page (`{ Modality, FeatureKey, Status, Skip, Limit }`). | `AIGenerationListView` |\n| `getGeneration(generationID)` | One generation (history). | `AIGenerationView` |\n| `isTerminal(status)` | `true` once polling can stop. | `boolean` |\n\n**Every generation is a job** (since 13.09.2026). A generate call only submits\nit and answers `pending` (text, image, edit, speech, music — queued on the\nserver) or `running` (video, 3D — submitted to the provider); the server never\nwaits for the model inside the request. Get the result with\n`waitForGeneration(view)` — it polls the right action until the status is\nfinal and returns the final view.\n\n### Two different kinds of failure — handle both\n\n```ts\nimport { parseAIErrorCode, AIErrorCode } from \"@idosgames/core\";\n\nconst res = await client.ai.generateImage(\"skin-gen\", { Prompt: text });\n\nif (!res.ok) {\n // 1. The REQUEST was refused (nothing generated, nothing charged) — or a\n // transport problem. Server refusals arrive as \"AI_CODE: safe message\".\n const code = parseAIErrorCode(res.error);\n if (code === AIErrorCode.LimitReached)\n return showToast(\"Come back tomorrow!\");\n if (code === AIErrorCode.Unavailable)\n return showToast(\"Not available right now.\");\n if (res.reason === \"connection\") return offerRetry(); // see Idempotency\n return showToast(res.error);\n}\n\nconst done = await client.ai.waitForGeneration(res.data);\nif (!done.ok) return showToast(\"Still working — check back later.\"); // timed out / aborted; the job keeps running\n\nif (done.data.Status === \"failed\") {\n // 2. The generation RAN and failed. The calls succeeded; PlayerRefunded says\n // the player got the price back. Show Error.Message, branch on Error.Code.\n return showToast(done.data.Error?.Message ?? \"Generation failed.\");\n}\n\nshowImage(done.data.Assets?.[0]?.Url);\n```\n\n`reason` is one of `\"client\"` (missing `featureKey` / `generationID`),\n`\"unauthorized\"`, `\"throttled\"`, `\"connection\"`, `\"validation\"`, `\"server\"`.\n\n## Recipes\n\n### AI NPC dialogue (text with history)\n\n```ts\nconst history: AIChatMessage[] = [];\n\nasync function say(playerLine: string) {\n const res = await client.ai.generateText(\"npc-chat\", {\n Prompt: playerLine,\n Messages: history, // earlier turns; the server keeps the feature's HistoryMessages last ones\n });\n if (!res.ok) return null;\n const done = await client.ai.waitForGeneration(res.data, {\n intervalMs: 1000,\n });\n if (!done.ok || done.data.Status !== \"completed\") return null;\n\n history.push({ Role: \"user\", Content: playerLine });\n history.push({ Role: \"assistant\", Content: done.data.OutputText ?? \"\" });\n return done.data.OutputText;\n}\n```\n\nThe NPC's persona is the feature's system prompt on the server — don't put it\nin `Prompt`. A reply takes a few seconds: show a \"thinking…\" state and disable\nthe send button. `intervalMs: 1000` shows a short reply quickly; the wait slows\ndown on its own for a long answer.\n\n### Picture in, text out — build a voxel world from a picture\n\nFor a Text feature with `AllowImages`, attach images to the prompt. Check the\nfeature's constraints before uploading so the player gets an instant message\ninstead of a round trip:\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"voxel-world\"] : undefined;\n\nasync function fileToInput(file: File): Promise<AIInputImage | string> {\n if (!feature?.AllowImages) return \"This feature does not take pictures.\";\n const types = feature.AllowedImageTypes ?? [\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n ];\n if (!types.includes(file.type)) return \"Use a PNG, JPEG or WebP picture.\";\n if (feature.MaxInputImageBytes && file.size > feature.MaxInputImageBytes)\n return \"The picture is too large.\";\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.onerror = () => reject(r.error);\n r.readAsDataURL(file);\n });\n return { Base64: dataUrl, ContentType: file.type }; // a data-URL is accepted as is\n}\n\nconst image = await fileToInput(file);\nif (typeof image === \"string\") return showToast(image);\n\nconst res = await client.ai.generateText(\"voxel-world\", {\n Prompt: \"Turn this picture into a voxel world. Answer with JSON only.\",\n Images: [image],\n});\nif (!res.ok) return showToast(res.error);\n\n// A whole world is a long answer (minutes): wait with progress, stop when the screen closes.\nconst done = await client.ai.waitForGeneration(res.data, {\n signal: screenAbort.signal,\n});\n\nconst world = parseWorldJson(done.ok ? done.data.OutputText : null); // validate it — it is model output\n```\n\nThe expected output format (\"JSON with blocks: [...]\") belongs in the feature's\nsystem prompt, so every player gets the same contract. Always validate the\nparsed structure: it is model output, not trusted data.\n\n### Generate an image, then edit it\n\n```ts\nconst img = await client.ai.generateImage(\"skin-gen\", {\n Prompt: \"a red dragon armour skin\",\n Size: \"1024x1024\", // ignored if the feature has LockBehavior\n});\nif (!img.ok) return;\nconst made = await client.ai.waitForGeneration(img.data);\nif (!made.ok || made.data.Status !== \"completed\") return;\nconst url = made.data.Assets?.[0]?.Url;\n\n// Feed a generated asset back in by URL — only assets THIS title generated are accepted.\nconst edit = await client.ai.editImage(\"skin-gen\", {\n Prompt: \"make it blue\",\n Images: [{ AssetUrl: url }],\n});\nconst edited = edit.ok ? await client.ai.waitForGeneration(edit.data) : edit;\n```\n\n### Waiting for a job — `waitForGeneration`\n\nNothing polls on its own (platform convention, as with chat): each poll is an\nAPI call billed to the publisher, so wait only while the player needs the\nresult.\n\n```ts\nconst job = await client.ai.generateVideo(\"trailer\", {\n Prompt: \"a dragon over a castle\",\n Seconds: 8,\n});\nif (job.ok) {\n const done = await client.ai.waitForGeneration(job.data, {\n signal: screenAbort.signal, // stop when the screen closes\n onUpdate: (v) => renderProgress(v.Progress), // 0..100 when the provider reports it\n });\n if (done.ok && done.data.Status === \"completed\")\n playVideo(done.data.Assets?.[0]?.Url);\n}\n```\n\n- Options: `intervalMs` (first pause, default 2000; grows ×1.5 up to\n `maxIntervalMs`, default 5000), `timeoutMs` (default 30 minutes), `signal`,\n `onUpdate`. A dropped connection does not end the wait; a timeout or the\n signal ends it with `reason: \"client\"` — the job keeps running on the server.\n- An abandoned job is not lost: save its `GenerationID` and resume later with\n `waitForGeneration(id)` (it reads the generation once to learn its modality),\n or find it in `listGenerations()`.\n- Polling by hand: use the action of the modality (`getText`, `getImage`,\n `getAudio`, `getMusic`, `getVideo`, `getThreeD`) — only it moves a provider\n job forward. `isTerminal` treats anything other than `pending` / `running`\n as final, so a status added later never makes a loop spin forever.\n- 3D `Assets` may hold several files — pick by `Variant` / `ContentType`: the\n model's `Variant` is its extension (`glb`, `fbx`, `splat`…), images are\n `thumbnail`, `front` / `left` / `back` / `right`, `uv`, `mask`. `rig-check`\n has no file at all: its answer is `OutputText` JSON.\n- Chains (`rig` → `retarget`, a model → `texture` / `convert`…) pass the earlier\n result as `SourceGenerationId` — the player's own finished generation, never\n a raw vendor id or a link. The feature, not the player, picks the operation.\n\n### Speech and music\n\n```ts\nconst voice = await client.ai.generateAudio(\"npc-voice\", {\n Text: \"Welcome, traveller!\",\n});\nconst spoken = voice.ok ? await client.ai.waitForGeneration(voice.data) : voice;\nif (spoken.ok && spoken.data.Status === \"completed\")\n new Audio(spoken.data.Assets?.[0]?.Url).play();\n\nconst track = await client.ai.generateMusic(\"level-music\", {\n Prompt: \"calm lofi for a farm level\",\n});\nconst music = track.ok ? await client.ai.waitForGeneration(track.data) : track;\n```\n\n### Idempotency and retries\n\nEach generate call sends a `RelatedEntityID`. Without `options` the SDK mints\na fresh one per call, so **two calls are two paid generations**. A retry with\nthe **same** key returns the original generation and is never charged twice.\n\nGenerate calls use a long timeout and **no automatic transport retry** (a\nresend could reach the server while the first attempt is still running). A\nnetwork failure therefore surfaces as `reason: \"connection\"`. To retry\nsafely, own the key:\n\n```ts\nconst key = crypto.randomUUID();\nlet res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n});\nif (!res.ok && res.reason === \"connection\")\n res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n }); // same generation\n```\n\nA key must be 1–128 characters of `A–Z a–z 0–9 _ -` (a UUID fits). Anything\nelse fails locally with `reason: \"client\"`: the server would strip the other\ncharacters and cut at 128, and two different keys could silently become one\ngeneration. Don't add the feature or player to the key — the server already\nscopes keys by title and player.\n\n### Pay with a specific option\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"skin-gen\"] : undefined;\n// feature.PriceOptions: Record<OptionID, PriceOption> — empty/absent = free for the player.\nawait client.ai.generateImage(\"skin-gen\", input, { selectedOptionID: \"gems\" });\n```\n\nOmit `selectedOptionID` to pay with the first available option. Filter options\nby platform with `client.checkout` before showing them, like any price.\n\n### Pay with a store purchase (IAP)\n\nAn option may be a store purchase (a `Purchase` entry): **one purchase pays for\nexactly one generation.** Buy the consumable in the native store, keep the\ntransaction PENDING, and send its receipt:\n\n```ts\nconst res = await client.ai.generateImage(\"skin-gen\", input, {\n selectedOptionID: \"store\",\n payment: { Store: \"GooglePlay\", Receipt: receipt },\n});\nconst done = res.ok ? await client.ai.waitForGeneration(res.data) : res;\nif (done.ok && done.data.PaymentSettled === true) finishStoreTransaction(); // consume now\n```\n\n- `PaymentSettled: true` — the generation is ready, the purchase is counted:\n finish (consume) the store transaction.\n- `PaymentSettled: false` on a job still in progress (every generation until it\n completes) — keep the transaction pending and finish it when a poll reports `true`.\n- `PaymentSettled: false` on a **failed** generation — keep the transaction and\n retry with the SAME receipt and a NEW `relatedEntityID`: the retry is free.\n- Re-sending a receipt that already paid returns that generation — it never\n pays for a second one. A publisher out of credits refuses BEFORE the receipt\n is taken, so the purchase is not burnt.\n\n### History\n\n```ts\nconst page = await client.ai.listGenerations({\n Modality: \"Image\",\n Status: \"completed\",\n Limit: 20,\n});\nconst one = await client.ai.getGeneration(generationID);\n```\n\nHistory reads never change the cache and emit no events.\n\n## Events\n\nSubscribe with `client.on(...)` (returns an unsubscribe fn):\n\n- `ai:definitionsLoaded` → `AIPublicDefinitions`\n- `ai:generationUpdated` → `AIGenerationView` — every generate and poll response\n- `ai:generationCompleted` → `AIGenerationView` — a generate/poll came back `completed`\n- `ai:generationFailed` → `AIGenerationView` — a generate/poll came back `failed`\n\nApplying a `Charge` also fires the usual resource events\n(`user:virtualCurrencyUpdated`, `user:inventoryUpdated`,\n`user:eventTokenUpdated`, `user:anyUpdated`).\n\n## Error codes\n\n`AIErrorCode` constants; a refusal's `error` reads `\"CODE: message\"`\n(`parseAIErrorCode`), a failed generation carries `Error.Code`.\n\n| Code | Meaning | What to show |\n| ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ |\n| `AI_DISABLED` | AI is off for the title | hide AI UI |\n| `AI_FEATURE_NOT_FOUND` / `AI_FEATURE_DISABLED` | no such / disabled feature | hide the entry point; refresh `getDefinitions()` |\n| `AI_WRONG_ACTION` | action ≠ feature modality | a bug in the game: fix the call |\n| `AI_NOT_AVAILABLE_FOR_PLAYER` | the feature's audience gate excludes this player | hide the entry point |\n| `AI_FEATURE_CLOSED` | outside the feature's schedule | \"not available now\" |\n| `AI_LIMIT_REACHED` / `AI_COOLDOWN` | the feature's `Limits` | \"come back later\" |\n| `AI_BAD_INPUT` | too long, too many / too large / wrong images, a foreign `AssetUrl` | the message |\n| `AI_PROMPT_REJECTED` | blocked terms | ask to rephrase |\n| `AI_UNAVAILABLE` | the publisher's side (credits, plan, caps) — reason deliberately hidden | a neutral \"not available right now\" |\n| `AI_GENERATION_FAILED` | the provider failed; everything charged is returned | \"try again\" |\n| `AI_GENERATION_NOT_FOUND` | unknown `GenerationID` | drop the stored id |\n\nTreat any other `AI_…` code generically — newer backends may add codes.\n\n## Gotchas\n\n- **A generate response is not the result.** It is `pending` / `running`; wait\n with `waitForGeneration`, then check the final `Status` — a failed generation\n is a successful call (`result.ok` ≠ \"generated\").\n- **One generate per action at a time.** A second `generateImage` while one is\n in flight is rejected with `reason: \"throttled\"` (the server serializes\n them per player and action anyway). Disable the button while waiting. Polls\n of _different_ jobs are not throttled against each other.\n- **The Charge is handled for you.** It is applied once, from the generate\n response. A generation with `PlayerRefunded: true` (failed after charging;\n the server returned the price) is never applied — its `Charge` still shows\n what was taken — and if this session had already applied it (every job is\n charged at submit), the SDK re-reads the inventory before emitting the\n event. A failed generation WITHOUT `PlayerRefunded` keeps its charge. Don't\n apply `Charge` yourself.\n- **Behaviour overrides are optional and may be ignored.** Size, quality,\n voice, image count… apply only when the feature has no `LockBehavior`;\n `MaxTokens` can only _lower_ the feature's value; `MaxPromptChars` counts the\n history too.\n- **Input images are checked by file signature**, not by the declared\n `ContentType`; `AssetUrl` must point to an asset this title generated.\n- **Don't cache `Assets[].Url` forever as the only copy** if the player owns\n the result — store the `GenerationID` and re-read it when needed.\n- **Unknown modality on an old SDK** falls back to `\"Text\"` when parsing\n definitions; the server still enforces the real one (`AI_WRONG_ACTION`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every request/response\nfield, the feature fields and their defaults (no platform caps — only the\nmodel's own limits), the server-side order of checks,\nand how charges, refunds and async jobs behave.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# AI generation data model — reference\n\nFull shapes of the requests, responses and the feature view, the hard caps a\nfeature cannot exceed, the order in which the server checks a request, and how\ncharges, refunds and async jobs behave. Everything is **strictly typed** and\nexported from `@idosgames/core` (`AIRequest`, `AITextInput`, …,\n`AIGenerationView`, `AIPublicDefinitions`, `AIPublicFeature`). Response schemas\nare not `.strict()` — fields the backend adds later still parse. Field names\nare PascalCase (straight from the backend JSON). Source of truth on the\nserver: `IDosGamesSDK/API/Client/v2/AI/Models/AIRequest.cs` and\n`AIDefinitions.cs` in `iDos_Games_Engine`.\n\n## Contents\n\n- [Route](#route)\n- [Request](#request)\n- [Responses](#responses)\n- [AIPublicFeature — what the client may send](#aipublicfeature--what-the-client-may-send)\n- [Hard caps](#hard-caps)\n- [Server order of checks](#server-order-of-checks)\n- [Charges, refunds, idempotency](#charges-refunds-idempotency)\n- [Jobs](#jobs)\n- [What the publisher configures](#what-the-publisher-configures)\n\n---\n\n## Route\n\n`POST {baseUrl}/api/v2/{titleID}/Client/AI/{Action}/{userID}` with\n`Authorization: Bearer {ClientSessionTicket}`; envelope\n`{ Success, Error?, Data }`. Actions (`AIAction`): `GetDefinitions`,\n`GenerateText`, `GetText`, `GenerateImage`, `EditImage`, `GetImage`,\n`GenerateVideo`, `GetVideo`, `GenerateAudio`, `GetAudio`, `GenerateMusic`,\n`GetMusic`, `GenerateThreeD`, `GetThreeD`, `ListGenerations`, `GetGeneration`.\n\nSDK transport settings: generate actions — timeout 200 s (they only submit a\njob; a video / 3D submit reaches the provider), **no** automatic transport\nretry; reads — the default 12 s with retries. Poll/read calls of one\ngeneration are throttled per generation, generate calls per action.\n\n## Request\n\n```ts\ninterface AIRequest extends BaseRequest {\n FeatureKey?: string; // required by every generate action\n SelectedOptionID?: string; // PriceOption.OptionID; absent = first available\n Text?: AITextInput; // GenerateText\n Image?: AIImageInput; // GenerateImage\n ImageEdit?: AIImageEditInput; // EditImage\n Video?: AIVideoInput; // GenerateVideo\n Audio?: AIAudioInput; // GenerateAudio\n Music?: AIMusicInput; // GenerateMusic\n ThreeD?: AIThreeDInput; // GenerateThreeD\n GenerationID?: string; // GetText / GetImage / GetAudio / GetMusic / GetVideo / GetThreeD / GetGeneration\n Query?: AIHistoryQuery; // ListGenerations\n // BaseRequest: UserID, ClientSessionTicket, BuildKey, RelatedEntityID (idempotency key), …\n}\n\ninterface AIInputImage {\n // exactly ONE source\n Base64?: string; // raw base64 or a data-URL\n ContentType?: string; // MIME of Base64\n AssetUrl?: string; // Url of an asset THIS title generated\n}\n\ninterface AITextInput {\n Prompt?: string;\n Messages?: { Role: \"user\" | \"assistant\"; Content: string }[]; // history\n Images?: AIInputImage[]; // only with AllowImages; attached to the last message\n MaxTokens?: number; // can only LOWER the feature's MaxTokens\n}\n\ninterface AIImageInput {\n Prompt?: string;\n Size?: string; // \"1024x1024\"\n Quality?: string; // \"low\" | \"medium\" | \"high\"\n Format?: string; // \"png\" | \"jpeg\" | \"webp\"\n Background?: string; // \"transparent\" | \"opaque\" | \"auto\"\n N?: number; // number of images, capped by MaxImages; absent/0 = 1\n}\n\ninterface AIImageEditInput {\n Prompt?: string;\n Images?: AIInputImage[];\n Size?: string;\n Format?: string;\n}\ninterface AIVideoInput {\n Prompt?: string;\n Size?: string;\n Seconds?: number;\n ReferenceImage?: AIInputImage;\n}\ninterface AIAudioInput {\n Text?: string;\n Voice?: string;\n Format?: string;\n} // \"mp3\" | \"wav\" | \"opus\"\ninterface AIMusicInput {\n Prompt?: string;\n Format?: string;\n} // \"mp3\" | \"wav\"\ninterface AIThreeDInput {\n Prompt?: string;\n Image?: AIInputImage;\n ArtStyle?: string;\n Format?: string;\n}\n// ArtStyle \"realistic\" | \"sculpture\"; Format \"glb\" | \"fbx\" | \"obj\" | \"usdz\"\n\ninterface AIHistoryQuery {\n Modality?: AIModality;\n FeatureKey?: string;\n Status?: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Skip?: number;\n Limit?: number; // 1..50, server default 20\n}\n```\n\nThere is intentionally **no** `Model`, prompt template or price field.\n\n## Responses\n\n```ts\ninterface AIGenerationView {\n GenerationID: string;\n FeatureKey?: string;\n Modality?: \"Text\" | \"Image\" | \"Video\" | \"Audio\" | \"Music\" | \"ThreeD\";\n Status: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Progress?: number; // 0..100, async jobs, when the provider reports it\n OutputText?: string; // Text\n Assets?: { Url?: string; ContentType?: string; Variant?: string }[];\n Error?: { Code?: string; Message?: string }; // failed generations; safe text, never vendor text\n Charge?: ResourceOperation; // in-game resources taken from the player\n PlayerRefunded?: boolean; // true = failed after charging, Charge was returned\n CreatedAt?: string; // ISO\n UpdatedAt?: string;\n}\n\ninterface AIGenerationListView {\n Data?: AIGenerationView[];\n Skip?: number;\n Limit?: number;\n}\n\ninterface AIPublicDefinitions {\n Enabled?: boolean; // master switch; false = every generate fails\n Features?: Record<string, AIPublicFeature>; // key = FeatureKey; disabled features are omitted\n}\n```\n\nNo model, usage or publisher price is ever returned.\n\n## AIPublicFeature — what the client may send\n\n| Field | Modality | Meaning |\n| ------------------------------------------------------------------------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------ |\n| `Modality` | all | which generate action the feature accepts |\n| `Async` | all | always `true` — every generation is a job; kept for old clients |\n| `Limits` | all | per-player anti-farm limits (`DailyCap`, `TotalCap`, `CooldownSeconds`, …; 0 = none) |\n| `PriceOptions` | all | the player's price per generation; empty = free; resources and/or ONE store purchase |\n| `Tags` | all | UI grouping only |\n| `HistoryMessages` | Text | how many history messages are kept (0 = one-shot) |\n| `MaxTokens` | Text | response token ceiling (client may only lower it) |\n| `AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes` | Text / edit | input image rules |\n| `MaxImages` | Image | cap of `N` |\n| `AllowEdit` | Image | `editImage` allowed |\n| `AllowReferenceImage` | Video | `ReferenceImage` allowed |\n| `MaxPromptChars` | all | prompt length limit incl. history (0 = none) |\n| `LockBehavior` | all | client overrides (size, voice, count…) are ignored |\n| `ImageSize`, `VideoSize`, `VideoSeconds`, `Voice`, `AudioFormat`, `MusicFormat`, `ThreeDOperation`, `ArtStyle`, `ThreeDFormat` | per modality | the feature's defaults, for UI |\n\n`ThreeDOperation` is `\"text-to-3d\"` (default) or `\"image-to-3d\"` (send\n`ThreeD.Image`).\n\n## Limits and defaults\n\n**There are no platform caps** (owner decision, 13.09.2026): a publisher may set\nany value the model behind the chosen **mode** supports. The only ceiling is that model —\ne.g. `MaxTokens` above the mode's model `MaxOutputTokens` is refused when the\nconfig is saved. What an empty field means:\n\n| What | Default when the feature leaves it empty |\n| ------------------------ | ----------------------------------------------- |\n| Text response tokens | 1024 (any value up to the model's output limit) |\n| Input images per request | 1 |\n| One input image | 4 MB (4194304 bytes) |\n| Input image types | png, jpeg, webp — checked by file signature |\n| Images per image request | 1 |\n\nRead the effective values from `AIPublicFeature` (`getDefinitions()`), never\nhard-code them in the game. A text generation has no time ceiling on the\nserver — it may run for hours while the model keeps writing (only 3 minutes of\nsilence counts as a broken stream), so long answers are fine; keep\n`waitForGeneration`'s `timeoutMs` in line with how long the player will wait. The publisher's credits are reserved for the upper bound (e.g.\n`MaxTokens`) before the call and the difference is returned after it — the\nplayer is not affected by that.\n\n## Server order of checks\n\nFeature (`cfg.AI`) → idempotency → the feature's mode / tier resolved to its registry model (enabled,\nmodality, image input, key, price) → gate, schedule, limits, prompt checks,\ninput images → cost estimate → the publisher's plan → the title's caps →\npublisher credit hold → player resources + counters (one atomic operation) →\nthe job is queued (video / 3D: submitted to the provider) → the generate call\nanswers `pending` / `running`. Then the server's worker: provider → assets\nstored → money finalized. A refusal before the player resources step charges\nnothing and comes back as `Success: false` with `Error = \"AI_CODE: message\"`.\nAnything failing **after** a charge returns everything the player paid.\n\nPublisher-side problems (no credits, plan without InApp AI, a mode / tier the\nplan does not include, model without a price, cap reached) are deliberately reported to the player as\n`AI_UNAVAILABLE` only.\n\n## Charges, refunds, idempotency\n\n- `Charge` is the `ResourceOperation` taken from the player (its `Consume`).\n The SDK applies it to the cache **once**, from the generate response, and\n never from polls or history (they carry the same `Charge`).\n- `PlayerRefunded: true` — the generation failed after charging and the\n server returned the player's resources. The view still shows the original\n `Charge`; the SDK never applies it, and if it applied it earlier in this\n session (an async job is charged at submit) it re-reads the inventory\n (`client.user.getUserInventory()`) before emitting the events. A `failed`\n generation with `PlayerRefunded: false` keeps its charge.\n- `RelatedEntityID` is the idempotency key, scoped by the server to title and\n player (`iaai_{title}_{user}_{key}`). The server keeps only `[A-Za-z0-9_-]`\n and the first 128 characters, so the SDK sends a plain UUID and rejects a\n caller key outside `^[A-Za-z0-9_-]{1,128}$` (`reason: \"client\"`) —\n otherwise two different keys could silently become one generation. A repeat\n returns the same generation and charges nothing.\n\n## Jobs\n\nEvery generate call returns a job: `pending` (text, image, edit, speech, music —\nqueued on the server and run by its worker within seconds) or `running`\n(video, 3D — submitted to the provider). Wait with `waitForGeneration`, or\npoll the modality's own action (`getText` / `getImage` / `getAudio` /\n`getMusic` / `getVideo` / `getThreeD`). The publisher's credits are reserved\nat submit; a video / 3D job is finalized lazily — on the player's poll, on a\nhistory read, or at the start of another generation of the title. A job nobody\npolls still resolves, but the client only learns the outcome by polling or\nreading history.\n\nThe server never repeats a provider call after a timeout (the provider would\nbill it twice). A generation that fails returns everything to the player; the\npublisher pays what the provider actually charged for it.\n\n## What the publisher configures\n\n`cfg.AI` is a **server-only** section of the title config (it holds prompts\nand models): it never reaches the client config or the CDN. Per feature the\npublisher sets `Enabled`, `Modality`, `Model` (the id of an AI **mode** for Text,\ne.g. `standard`, or of a generation **tier** for media, e.g. `image-balanced` —\nthe same list the publisher sees in the AI Coder and in asset generation, from\nthe dashboard action `GetInAppAIModels`; never a model name — the server refuses\nit), `Behavior` (system instructions and the\nmodality defaults above), `Safety` (`MaxPromptChars`, `BlockedTerms`,\n`LockBehavior`), `Gate` (audience), `Schedule`, `Limits`, `PriceOptions`,\n`Tags` — in the dashboard or via the backend title-config MCP (`save_ai`).\nWhen a game needs a feature that doesn't exist, tell the publisher what to\nconfigure; don't work around it on the client.\n"
|
|
8
|
+
"content": "# AI generation data model — reference\n\nFull shapes of the requests, responses and the feature view, the hard caps a\nfeature cannot exceed, the order in which the server checks a request, and how\ncharges, refunds and async jobs behave. Everything is **strictly typed** and\nexported from `@idosgames/core` (`AIRequest`, `AITextInput`, …,\n`AIGenerationView`, `AIPublicDefinitions`, `AIPublicFeature`). Response schemas\nare not `.strict()` — fields the backend adds later still parse. Field names\nare PascalCase (straight from the backend JSON). Source of truth on the\nserver: `IDosGamesSDK/API/Client/v2/AI/Models/AIRequest.cs` and\n`AIDefinitions.cs` in `iDos_Games_Engine`.\n\n## Contents\n\n- [Route](#route)\n- [Request](#request)\n- [Responses](#responses)\n- [AIPublicFeature — what the client may send](#aipublicfeature--what-the-client-may-send)\n- [Hard caps](#hard-caps)\n- [Server order of checks](#server-order-of-checks)\n- [Charges, refunds, idempotency](#charges-refunds-idempotency)\n- [Jobs](#jobs)\n- [What the publisher configures](#what-the-publisher-configures)\n\n---\n\n## Route\n\n`POST {baseUrl}/api/v2/{titleID}/Client/AI/{Action}/{userID}` with\n`Authorization: Bearer {ClientSessionTicket}`; envelope\n`{ Success, Error?, Data }`. Actions (`AIAction`): `GetDefinitions`,\n`GenerateText`, `GetText`, `GenerateImage`, `EditImage`, `GetImage`,\n`GenerateVideo`, `GetVideo`, `GenerateAudio`, `GetAudio`, `GenerateMusic`,\n`GetMusic`, `GenerateThreeD`, `GetThreeD`, `ListGenerations`, `GetGeneration`.\n\nSDK transport settings: generate actions — timeout 200 s (they only submit a\njob; a video / 3D submit reaches the provider), **no** automatic transport\nretry; reads — the default 12 s with retries. Poll/read calls of one\ngeneration are throttled per generation, generate calls per action.\n\n## Request\n\n```ts\ninterface AIRequest extends BaseRequest {\n FeatureKey?: string; // required by every generate action\n SelectedOptionID?: string; // PriceOption.OptionID; absent = first available\n Text?: AITextInput; // GenerateText\n Image?: AIImageInput; // GenerateImage\n ImageEdit?: AIImageEditInput; // EditImage\n Video?: AIVideoInput; // GenerateVideo\n Audio?: AIAudioInput; // GenerateAudio\n Music?: AIMusicInput; // GenerateMusic\n ThreeD?: AIThreeDInput; // GenerateThreeD\n GenerationID?: string; // GetText / GetImage / GetAudio / GetMusic / GetVideo / GetThreeD / GetGeneration\n Query?: AIHistoryQuery; // ListGenerations\n // BaseRequest: UserID, ClientSessionTicket, BuildKey, RelatedEntityID (idempotency key), …\n}\n\ninterface AIInputImage {\n // exactly ONE source\n Base64?: string; // raw base64 or a data-URL\n ContentType?: string; // MIME of Base64\n AssetUrl?: string; // Url of an asset THIS title generated\n}\n\ninterface AITextInput {\n Prompt?: string;\n Messages?: { Role: \"user\" | \"assistant\"; Content: string }[]; // history\n Images?: AIInputImage[]; // only with AllowImages; attached to the last message\n MaxTokens?: number; // can only LOWER the feature's MaxTokens\n}\n\ninterface AIImageInput {\n Prompt?: string;\n Size?: string; // \"1024x1024\"\n Quality?: string; // \"low\" | \"medium\" | \"high\"\n Format?: string; // \"png\" | \"jpeg\" | \"webp\"\n Background?: string; // \"transparent\" | \"opaque\" | \"auto\"\n N?: number; // number of images, capped by MaxImages; absent/0 = 1\n}\n\ninterface AIImageEditInput {\n Prompt?: string;\n Images?: AIInputImage[];\n Size?: string;\n Format?: string;\n}\ninterface AIVideoInput {\n Prompt?: string;\n Size?: string;\n Seconds?: number;\n ReferenceImage?: AIInputImage;\n}\ninterface AIAudioInput {\n Text?: string;\n Voice?: string;\n Format?: string;\n} // \"mp3\" | \"wav\" | \"opus\"\ninterface AIMusicInput {\n Prompt?: string;\n Format?: string;\n} // \"mp3\" | \"wav\"\ninterface AIThreeDInput {\n Prompt?: string; // text-to-3d; texture (what it looks like)\n NegativePrompt?: string; // text-to-3d\n Image?: AIInputImage; // image-to-3d, image-to-splat, image-to-multiview, smart-segment; texture reference\n Front?: AIInputImage; // multiview-to-3d views (front required, 2+ views)\n Left?: AIInputImage;\n Back?: AIInputImage;\n Right?: AIInputImage;\n SourceGenerationId?: string; // the player's OWN finished 3D generation to process\n Animations?: string[]; // retarget: \"preset:walk\"... at most 5\n PartNames?: string[]; // complete\n EditPrompts?: { View: string; Prompt: string }[]; // edit-multiview\n Hint?: string; // smart-segment\n}\n// The OPERATION is fixed by the feature (ThreeDOperation), never by the input.\n\ninterface AIHistoryQuery {\n Modality?: AIModality;\n FeatureKey?: string;\n Status?: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Skip?: number;\n Limit?: number; // 1..50, server default 20\n}\n```\n\nThere is intentionally **no** `Model`, prompt template or price field.\n\n## Responses\n\n```ts\ninterface AIGenerationView {\n GenerationID: string;\n FeatureKey?: string;\n Modality?: \"Text\" | \"Image\" | \"Video\" | \"Audio\" | \"Music\" | \"ThreeD\";\n Status: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Progress?: number; // 0..100, async jobs, when the provider reports it\n OutputText?: string; // Text\n Assets?: { Url?: string; ContentType?: string; Variant?: string }[];\n Error?: { Code?: string; Message?: string }; // failed generations; safe text, never vendor text\n Charge?: ResourceOperation; // in-game resources taken from the player\n PlayerRefunded?: boolean; // true = failed after charging, Charge was returned\n CreatedAt?: string; // ISO\n UpdatedAt?: string;\n}\n\ninterface AIGenerationListView {\n Data?: AIGenerationView[];\n Skip?: number;\n Limit?: number;\n}\n\ninterface AIPublicDefinitions {\n Enabled?: boolean; // master switch; false = every generate fails\n Features?: Record<string, AIPublicFeature>; // key = FeatureKey; disabled features are omitted\n}\n```\n\nNo model, usage or publisher price is ever returned.\n\n## AIPublicFeature — what the client may send\n\n| Field | Modality | Meaning |\n| ---------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------ |\n| `Modality` | all | which generate action the feature accepts |\n| `Async` | all | always `true` — every generation is a job; kept for old clients |\n| `Limits` | all | per-player anti-farm limits (`DailyCap`, `TotalCap`, `CooldownSeconds`, …; 0 = none) |\n| `PriceOptions` | all | the player's price per generation; empty = free; resources and/or ONE store purchase |\n| `Tags` | all | UI grouping only |\n| `HistoryMessages` | Text | how many history messages are kept (0 = one-shot) |\n| `MaxTokens` | Text | response token ceiling (client may only lower it) |\n| `AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes` | Text / edit | input image rules |\n| `MaxImages` | Image | cap of `N` |\n| `AllowEdit` | Image | `editImage` allowed |\n| `AllowReferenceImage` | Video | `ReferenceImage` allowed |\n| `MaxPromptChars` | all | prompt length limit incl. history (0 = none) |\n| `LockBehavior` | all | client overrides (size, voice, count…) are ignored |\n| `ImageSize`, `VideoSize`, `VideoSeconds`, `Voice`, `AudioFormat`, `MusicFormat`, `ThreeDOperation`, `ThreeDAnimations` | per modality | the feature's defaults, for UI |\n\n`ThreeDOperation` is what the feature does (empty = `\"text-to-3d\"`):\n\n| Operation | Player sends | Result |\n| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |\n| `text-to-3d` | `Prompt` (+ `NegativePrompt`) | model + preview |\n| `image-to-3d` | `Image` | model + preview |\n| `multiview-to-3d` | `Front` + 1..3 of `Left`/`Back`/`Right`, or `SourceGenerationId` of an `image-to-multiview` / `edit-multiview` | model + preview |\n| `image-to-splat` | `Image` | `.splat` file |\n| `image-to-multiview` | `Image` | 4 view images (`front`/`left`/`back`/`right` assets) |\n| `edit-multiview` | `SourceGenerationId` + `EditPrompts` | 4 view images |\n| `texture` | `SourceGenerationId` + `Prompt` or `Image` or views | model |\n| `retopology`, `smart-uv`, `segment`, `convert`, `rig` | `SourceGenerationId` | model (`smart-uv` also a `uv` image) |\n| `smart-segment` | `Image` or `SourceGenerationId` (+ `Hint`) | model + `mask`; part names in `OutputText` |\n| `complete` | `SourceGenerationId` of a segmentation (+ `PartNames`) | model |\n| `rig-check` | `SourceGenerationId` | no file: `OutputText` JSON `{ \"riggable\": true, \"rig_type\": \"biped\" }` |\n| `retarget` | `SourceGenerationId` of a `rig` + `Animations` (within `ThreeDAnimations`) | animated model |\n\nQuality, polygon limit, rig type, convert format and the like are set by the publisher in the\nfeature (`ThreeDOptions`) and are not sent by the player. `SourceGenerationId` must be the same\nplayer's finished generation in the same game; anything else is refused before any charge.\n\n## Limits and defaults\n\n**There are no platform caps** (owner decision, 13.09.2026): a publisher may set\nany value the model behind the chosen **mode** supports. The only ceiling is that model —\ne.g. `MaxTokens` above the mode's model `MaxOutputTokens` is refused when the\nconfig is saved. What an empty field means:\n\n| What | Default when the feature leaves it empty |\n| ------------------------ | ----------------------------------------------- |\n| Text response tokens | 1024 (any value up to the model's output limit) |\n| Input images per request | 1 |\n| One input image | 4 MB (4194304 bytes) |\n| Input image types | png, jpeg, webp — checked by file signature |\n| Images per image request | 1 |\n\nRead the effective values from `AIPublicFeature` (`getDefinitions()`), never\nhard-code them in the game. A text generation has no time ceiling on the\nserver — it may run for hours while the model keeps writing (only 3 minutes of\nsilence counts as a broken stream), so long answers are fine; keep\n`waitForGeneration`'s `timeoutMs` in line with how long the player will wait. The publisher's credits are reserved for the upper bound (e.g.\n`MaxTokens`) before the call and the difference is returned after it — the\nplayer is not affected by that.\n\n## Server order of checks\n\nFeature (`cfg.AI`) → idempotency → the feature's mode / tier resolved to its registry model (enabled,\nmodality, image input, key, price) → gate, schedule, limits, prompt checks,\ninput images → cost estimate → the publisher's plan → the title's caps →\npublisher credit hold → player resources + counters (one atomic operation) →\nthe job is queued (video / 3D: submitted to the provider) → the generate call\nanswers `pending` / `running`. Then the server's worker: provider → assets\nstored → money finalized. A refusal before the player resources step charges\nnothing and comes back as `Success: false` with `Error = \"AI_CODE: message\"`.\nAnything failing **after** a charge returns everything the player paid.\n\nPublisher-side problems (no credits, plan without InApp AI, a mode / tier the\nplan does not include, model without a price, cap reached) are deliberately reported to the player as\n`AI_UNAVAILABLE` only.\n\n## Charges, refunds, idempotency\n\n- `Charge` is the `ResourceOperation` taken from the player (its `Consume`).\n The SDK applies it to the cache **once**, from the generate response, and\n never from polls or history (they carry the same `Charge`).\n- `PlayerRefunded: true` — the generation failed after charging and the\n server returned the player's resources. The view still shows the original\n `Charge`; the SDK never applies it, and if it applied it earlier in this\n session (an async job is charged at submit) it re-reads the inventory\n (`client.user.getUserInventory()`) before emitting the events. A `failed`\n generation with `PlayerRefunded: false` keeps its charge.\n- `RelatedEntityID` is the idempotency key, scoped by the server to title and\n player (`iaai_{title}_{user}_{key}`). The server keeps only `[A-Za-z0-9_-]`\n and the first 128 characters, so the SDK sends a plain UUID and rejects a\n caller key outside `^[A-Za-z0-9_-]{1,128}$` (`reason: \"client\"`) —\n otherwise two different keys could silently become one generation. A repeat\n returns the same generation and charges nothing.\n\n## Jobs\n\nEvery generate call returns a job: `pending` (text, image, edit, speech, music —\nqueued on the server and run by its worker within seconds) or `running`\n(video, 3D — submitted to the provider). Wait with `waitForGeneration`, or\npoll the modality's own action (`getText` / `getImage` / `getAudio` /\n`getMusic` / `getVideo` / `getThreeD`). The publisher's credits are reserved\nat submit; a video / 3D job is finalized lazily — on the player's poll, on a\nhistory read, or at the start of another generation of the title. A job nobody\npolls still resolves, but the client only learns the outcome by polling or\nreading history.\n\nThe server never repeats a provider call after a timeout (the provider would\nbill it twice). A generation that fails returns everything to the player; the\npublisher pays what the provider actually charged for it.\n\n## What the publisher configures\n\n`cfg.AI` is a **server-only** section of the title config (it holds prompts\nand models): it never reaches the client config or the CDN. Per feature the\npublisher sets `Enabled`, `Modality`, `Model` (the id of an AI **mode** for Text,\ne.g. `standard`, or of a generation **tier** for media, e.g. `image-balanced` —\nthe same list the publisher sees in the AI Coder and in asset generation, from\nthe dashboard action `GetInAppAIModels`; never a model name — the server refuses\nit), `Behavior` (system instructions and the\nmodality defaults above), `Safety` (`MaxPromptChars`, `BlockedTerms`,\n`LockBehavior`), `Gate` (audience), `Schedule`, `Limits`, `PriceOptions`,\n`Tags` — in the dashboard or via the backend title-config MCP (`save_ai`).\nWhen a game needs a feature that doesn't exist, tell the publisher what to\nconfigure; don't work around it on the client.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -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 get a\n soft shadow (`panelShadow`), 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",
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rewarded-ads",
|
|
3
3
|
"description": "Add rewarded ads (\"watch a video for a reward\") to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.ads.showRewarded(placementID). Inside the iDos Games mobile app the app shows the video (or the player spends an ad unit) and the server grants the placement's ad credits; outside the app there are no web ads and the call answers `unavailable`, which the game must handle. Use this whenever the user wants rewarded video, \"watch an ad to continue / double the reward / get coins\", ad credits, an ad button, or touches client.ads, AdsService, Advertising placements, RewardedVideoCredit, RequestAdPermit, CompleteView, even if they don't say \"ads\".",
|
|
4
|
-
"content": "---\nname: rewarded-ads\ndescription: >-\n Add rewarded ads (\"watch a video for a reward\") to a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.ads.showRewarded(placementID).\n Inside the iDos Games mobile app the app shows the video (or the player\n spends an ad unit) and the server grants the placement's ad credits; outside\n the app there are no web ads and the call answers `unavailable`, which the\n game must handle. Use this whenever the user wants rewarded video, \"watch an\n ad to continue / double the reward / get coins\", ad credits, an ad button, or\n touches client.ads, AdsService, Advertising placements, RewardedVideoCredit,\n RequestAdPermit, CompleteView, even if they don't say \"ads\".\n---\n\n# Rewarded ads (iDosGames TS SDK)\n\nOnly **rewarded** ads exist for web games (owner, 2026-10-30): no banners, no\ninterstitials. Everything is **server-authoritative**: the title's\n`Advertising` config decides placements, the reward, cooldowns and daily caps,\nand the reward is granted only after the ad network's server-side confirmation\nor a spent ad unit. The game never grants an ad reward itself.\n\n## Where ads show\n\n- **iDos Games mobile app (Android)**: the app around the game shows the video\n through the platform's LevelPlay. The player may instead spend an **ad unit**\n (earned by watching ads from the app icon). The author of the game gets 20%\n of each view's revenue.\n- **Anywhere else** (the website, a browser, the AI Coder preview): no web ad\n SDK exists, so `showRewarded` answers `unavailable`. Hide or disable the\n button with `client.ads.available`, and never block progress on an ad.\n- **Games where players can withdraw get no ads in the app** (`denied`, reason\n `WithdrawalsOpen`): an ad reward must never become withdrawable. A game with\n its own token shows ads as long as withdrawals are off.\n- **Ad credits never buy crypto or an NFT item**: the server refuses such a price\n (`AD_CREDIT_FOR_WITHDRAWABLE_FORBIDDEN`). Pay for ads with game currency and\n regular items.\n\n## Config the game needs\n\nA rewarded placement in the title's `Advertising` section (dashboard or MCP),\nfor example `rv_continue` with `Type: RewardedVideo` and `CreditsRewarded: 5`.\nThe reward is `RewardedVideoCredit` (ad credits). Spend them on what the ad was\nfor through a server operation that costs `RewardedVideoCredit`, or through\n`Advertising.CreditSinks` for purely client-side effects.\n\n## Code\n\n```ts\nconst result = await client.ads.showRewarded(\"rv_continue\");\nswitch (result.status) {\n case \"granted\":
|
|
4
|
+
"content": "---\nname: rewarded-ads\ndescription: >-\n Add rewarded ads (\"watch a video for a reward\") to a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.ads.showRewarded(placementID).\n Inside the iDos Games mobile app the app shows the video (or the player\n spends an ad unit) and the server grants the placement's ad credits; outside\n the app there are no web ads and the call answers `unavailable`, which the\n game must handle. Use this whenever the user wants rewarded video, \"watch an\n ad to continue / double the reward / get coins\", ad credits, an ad button, or\n touches client.ads, AdsService, Advertising placements, RewardedVideoCredit,\n RequestAdPermit, CompleteView, even if they don't say \"ads\".\n---\n\n# Rewarded ads (iDosGames TS SDK)\n\nOnly **rewarded** ads exist for web games (owner, 2026-10-30): no banners, no\ninterstitials. Everything is **server-authoritative**: the title's\n`Advertising` config decides placements, the reward, cooldowns and daily caps,\nand the reward is granted only after the ad network's server-side confirmation\nor a spent ad unit. The game never grants an ad reward itself.\n\n## Where ads show\n\n- **iDos Games mobile app (Android)**: the app around the game shows the video\n through the platform's LevelPlay. The player may instead spend an **ad unit**\n (earned by watching ads from the app icon). The author of the game gets 20%\n of each view's revenue.\n- **Anywhere else** (the website, a browser, the AI Coder preview): no web ad\n SDK exists, so `showRewarded` answers `unavailable`. Hide or disable the\n button with `client.ads.available`, and never block progress on an ad.\n- **Games where players can withdraw get no ads in the app** (`denied`, reason\n `WithdrawalsOpen`): an ad reward must never become withdrawable. A game with\n its own token shows ads as long as withdrawals are off.\n- **Ad credits never buy crypto or an NFT item**: the server refuses such a price\n (`AD_CREDIT_FOR_WITHDRAWABLE_FORBIDDEN`). Pay for ads with game currency and\n regular items.\n\n## Config the game needs\n\nA rewarded placement in the title's `Advertising` section (dashboard or MCP),\nfor example `rv_continue` with `Type: RewardedVideo` and `CreditsRewarded: 5`.\nThe reward is `RewardedVideoCredit` (ad credits). Spend them on what the ad was\nfor through a server operation that costs `RewardedVideoCredit`, or through\n`Advertising.CreditSinks` for purely client-side effects.\n\n## Code\n\n```ts\nconst result = await client.ads.showRewarded(\"rv_continue\");\nswitch (result.status) {\n case \"granted\": // result.creditsAwarded ad credits are on the balance now\n break;\n case \"pending\": // watched; the server still waits for the network: the\n break; // credits arrive on their own, re-read state later\n case \"denied\": // cooldown / daily cap / open withdrawals: result.reason,\n break; // result.retryAfterSeconds\n case \"cancelled\": // the player closed the sheet or the video\n case \"unavailable\": // no ads here: offer another way, do not block the game\n break;\n}\n```\n\nRules:\n\n1. Call it only from a player's tap, one call at a time.\n2. Treat `unavailable` as normal, not as an error: most players on the web\n have no ads.\n3. Do not trust the client for the reward: show the result, then use the\n credits through a server operation.\n4. Do not show your own countdown or fake video: the app owns the UI.\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. Use the one call\nthat 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",
|