@idosgames/mcp 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli.js +1597 -1542
  2. package/package.json +1 -1
  3. package/registry/host.json +3 -3
  4. package/registry/index.json +172 -125
  5. package/registry/modules/ai.json +5 -5
  6. package/registry/modules/board-game.json +6 -6
  7. package/registry/modules/boosts.json +5 -5
  8. package/registry/modules/character.json +9 -9
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +5 -5
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +5 -5
  13. package/registry/modules/deals.json +6 -6
  14. package/registry/modules/events.json +3 -3
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/hero-run.json +15 -234
  17. package/registry/modules/idle-rpg.json +8 -8
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/kit-game.json +171 -0
  21. package/registry/modules/leaderboard.json +3 -3
  22. package/registry/modules/lootboxes.json +5 -5
  23. package/registry/modules/mailbox.json +3 -3
  24. package/registry/modules/marketplace.json +3 -3
  25. package/registry/modules/mini-game.json +3 -3
  26. package/registry/modules/multiplayer.json +3 -3
  27. package/registry/modules/premium.json +5 -5
  28. package/registry/modules/profile.json +3 -3
  29. package/registry/modules/pvp.json +3 -3
  30. package/registry/modules/quests.json +3 -3
  31. package/registry/modules/rewards.json +3 -3
  32. package/registry/modules/season.json +3 -3
  33. package/registry/modules/store.json +6 -6
  34. package/registry/modules/tutorials.json +3 -3
  35. package/registry/modules/video-studio.json +9 -9
  36. package/registry/modules/voxelcraft.json +6 -6
  37. package/registry/modules/workshop.json +6 -6
  38. package/registry/skills/currency-system.json +1 -1
  39. package/registry/skills/idosgames-compose-modules.json +1 -1
  40. package/registry/skills/idosgames-getting-started.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +1 -1
  42. package/registry/skills/store-system.json +1 -1
  43. package/registry/video.json +1 -1
@@ -84,14 +84,14 @@
84
84
  "version": "0.7.4"
85
85
  },
86
86
  "dependencies": {
87
- "@idosgames/characters": "0.1.2",
88
- "@idosgames/core": "0.25.0",
89
- "@idosgames/module-sdk": "0.13.2",
90
- "@idosgames/react": "0.6.3",
87
+ "@idosgames/characters": "0.1.3",
88
+ "@idosgames/core": "0.26.1",
89
+ "@idosgames/module-sdk": "0.13.3",
90
+ "@idosgames/react": "0.7.1",
91
91
  "react": "19.2.7",
92
92
  "three": "0.185.1"
93
93
  },
94
- "contentHash": "fd96e0faccf698f9fa5c169bd51acbe1b11b1eae3eba6f6dfc5dd8ab72df438e",
94
+ "contentHash": "5068fee9f43c38b2967a4771de445afa23699a0ed5bc16fe9ecd891757dd0730",
95
95
  "files": [
96
96
  {
97
97
  "path": "agent.ts",
@@ -107,7 +107,7 @@
107
107
  },
108
108
  {
109
109
  "path": "ai/specAi.ts",
110
- "content": "// Общая ИИ-генерация РЕЦЕПТА поверх client.ai (@idosgames/core): мир, персонаж, облик предмета —\n// одна и та же механика, разные фича, промпт и разбор ответа. Правила — по скиллу\n// ai-generation-system:\n//\n// - клиент называет только фичу: модель, системный промпт и цена — в конфиге тайтла;\n// - отказ (ok: false, \"AI_CODE: сообщение\") и провал генерации (Status: \"failed\") — разные вещи;\n// - у generate нет авто-ретрая транспорта: при обрыве связи повторяем САМИ и с тем же\n// relatedEntityID — сервер вернёт ту же генерацию и второй раз не спишет;\n// - асинхронную задачу опрашиваем сами, пока игрок ждёт; закрыл экран — задача дорабатывает на\n// сервере, её GenerationID сохраняется, и resume() дождётся результата в следующий раз.\n// От @idosgames/core — только типы: сам клиент модулю отдаёт хост (ctx.client), runtime-код core\n// модулю не нужен. Поэтому коды отказов ниже — литералами, зеркалом AIErrorCode из core.\nimport type {\n AIGenerationView,\n AIPublicFeature,\n AITextInput,\n IDosGamesClient,\n OperationResult,\n} from \"@idosgames/core\";\nimport { tr, type Loc } from \"../i18n\";\nimport type {\n WorldAIFailure,\n WorldAIFeature,\n WorldAIProgress,\n WorldAIStatus,\n} from \"./types\";\n\ntype Failure = Extract<OperationResult<unknown>, { ok: false }>;\n\n/** Коды отказов AI-модуля (AIErrorCode в @idosgames/core; сервер — InAppAIErrors). */\nconst AIErrorCode = {\n Disabled: \"AI_DISABLED\",\n FeatureNotFound: \"AI_FEATURE_NOT_FOUND\",\n FeatureDisabled: \"AI_FEATURE_DISABLED\",\n NotAvailableForPlayer: \"AI_NOT_AVAILABLE_FOR_PLAYER\",\n FeatureClosed: \"AI_FEATURE_CLOSED\",\n LimitReached: \"AI_LIMIT_REACHED\",\n Cooldown: \"AI_COOLDOWN\",\n BadInput: \"AI_BAD_INPUT\",\n PromptRejected: \"AI_PROMPT_REJECTED\",\n Unavailable: \"AI_UNAVAILABLE\",\n GenerationNotFound: \"AI_GENERATION_NOT_FOUND\",\n} as const;\n\n/** Код из отказа \"AI_COOLDOWN: …\" (как parseAIErrorCode в core); null — отказ без кода. */\nexport function parseAIErrorCode(\n error: string | null | undefined,\n): string | null {\n if (!error) return null;\n return /\\b(AI_[A-Z_]+)\\s*:/.exec(error)?.[1] ?? null;\n}\n\nexport type SpecAIFeature = WorldAIFeature;\nexport type SpecAIStatus = WorldAIStatus;\nexport type SpecAIProgress = WorldAIProgress;\n\nexport type SpecAIResult<S> =\n | { ok: true; spec: S; warnings: string[] }\n | { ok: false; kind: WorldAIFailure; message: string; refunded?: boolean };\n\n/** Разбор текста ответа модели. */\nexport type SpecParse<S> =\n | { ok: true; spec: S; warnings: string[] }\n | { ok: false; truncated?: boolean };\n\nexport interface SpecAIImage {\n dataUrl: string;\n contentType: string;\n}\n\n/** Что отличает один вид генерации от другого. */\nexport interface SpecAIKind<Req, S, Extra = null> {\n featureKey: string;\n /** Суффикс ключа незаконченной задачи в localStorage. */\n pendingName: string;\n buildPrompt(req: Req, maxPromptChars: number | null): string;\n images(req: Req): SpecAIImage[];\n /** То, что надо помнить вместе с задачей до ответа (у мира — размер). */\n extra(req: Req): Extra;\n /** Ответ модели → рецепт (extra — то, что запомнили при запуске). */\n parse(text: string, extra: Extra): SpecParse<S>;\n /** Тексты для игрока. */\n texts: {\n /** «миры», «персонажей», «предметы» — «ИИ-генерация … не настроена». */\n what: Loc;\n /** «ИИ строит мир…» */\n working: Loc;\n /** «Войдите в игру, чтобы строить миры с ИИ.» */\n login: Loc;\n /** «ИИ не смог построить мир.» */\n failed: Loc;\n /** «ИИ ответил не рецептом мира.» */\n invalid: Loc;\n /** «Ответ ИИ оборван — мир получился слишком длинным.» */\n truncated: Loc;\n /** «Дожидаюсь мира с прошлого раза…» */\n resuming: Loc;\n /** «ИИ отвечает слишком долго. Откройте «Создать мир» позже — результат дождётся вас.» */\n slow: Loc;\n };\n}\n\nexport interface SpecAIOptions {\n featureKey?: string;\n pollMs?: number;\n timeoutMs?: number;\n /** localStorage по умолчанию; null — не сохранять задачу между запусками. */\n storage?: Pick<Storage, \"getItem\" | \"setItem\" | \"removeItem\"> | null;\n sleep?: (ms: number, signal: AbortSignal) => Promise<void>;\n}\n\nexport interface SpecAI<Req, S> {\n status(): Promise<SpecAIStatus>;\n generate(\n req: Req,\n opts: { signal: AbortSignal; onProgress: SpecAIProgress },\n ): Promise<SpecAIResult<S>>;\n /** Недождавшаяся задача с прошлого раза, если есть: дождаться её результата. */\n resume(opts: {\n signal: AbortSignal;\n onProgress: SpecAIProgress;\n }): Promise<SpecAIResult<S> | null>;\n}\n\nconst DEFAULT_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\"];\n\nfunction defaultStorage(): SpecAIOptions[\"storage\"] {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null; // хранилище запрещено (фрейм, настройки браузера)\n }\n}\n\nfunction abortableSleep(ms: number, signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n const t = setTimeout(resolve, ms);\n signal.addEventListener(\"abort\", () => {\n clearTimeout(t);\n resolve();\n });\n });\n}\n\nfunction newKey(): string {\n return typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID()\n : Date.now().toString(36) + \"-\" + Math.random().toString(36).slice(2, 12);\n}\n\n/** «10 GEM» из первой опции цены; null — бесплатно. */\nexport function priceLabel(\n options: AIPublicFeature[\"PriceOptions\"],\n): string | null {\n const first = options ? Object.values(options)[0] : undefined;\n if (!first) return null;\n if (first.Name) return first.Name;\n const parts = (first.Cost?.Standard?.Entries ?? [])\n .map((e) => `${e.Amount ?? \"\"} ${e.CurrencyID ?? e.ItemID ?? \"\"}`.trim())\n .filter(Boolean);\n return parts.length ? parts.join(\" + \") : null;\n}\n\nfunction toFeature(f: AIPublicFeature): SpecAIFeature {\n return {\n allowImages: f.AllowImages === true,\n maxImageBytes: f.MaxInputImageBytes || null,\n imageTypes: f.AllowedImageTypes?.length\n ? f.AllowedImageTypes\n : DEFAULT_TYPES,\n async: f.Async === true,\n priceLabel: priceLabel(f.PriceOptions),\n maxPromptChars: f.MaxPromptChars || null,\n // у фичи по умолчанию одна картинка; потолка платформы нет — сколько разрешил издатель\n maxImages: Math.max(1, f.MaxInputImages || 1),\n };\n}\n\n/** Безопасное сообщение из отказа \"AI_CODE: сообщение\". */\nfunction messageOf(error: string): string {\n const m = /\\bAI_[A-Z_]+\\s*:\\s*([\\s\\S]*)$/.exec(error);\n return (m?.[1] ?? error).trim();\n}\n\ntype Refusal = Extract<SpecAIResult<unknown>, { ok: false }>;\n\n/** Отказ сервера (ничего не сгенерировано и не списано) — в понятный игроку текст. */\nfunction refusal(res: Failure, login: Loc, what: Loc): Refusal {\n if (res.reason === \"connection\")\n return {\n ok: false,\n kind: \"connection\",\n message: tr({\n en: \"No connection to the server. Try again.\",\n ru: \"Нет связи с сервером. Попробуйте ещё раз.\",\n }),\n };\n if (res.reason === \"unauthorized\")\n return { ok: false, kind: \"refused\", message: tr(login) };\n if (res.reason === \"throttled\")\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"A generation is already running — wait for it.\",\n ru: \"Генерация уже идёт — дождитесь её.\",\n }),\n };\n const code = parseAIErrorCode(res.error);\n switch (code) {\n case AIErrorCode.LimitReached:\n case AIErrorCode.Cooldown:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"The AI generation limit is used up for now. Come back later.\",\n ru: \"Лимит ИИ-генераций на сейчас исчерпан. Загляните позже.\",\n }),\n };\n case AIErrorCode.PromptRejected:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"The description did not pass the check. Rephrase it.\",\n ru: \"Описание не прошло проверку. Переформулируйте его.\",\n }),\n };\n case AIErrorCode.BadInput:\n return { ok: false, kind: \"refused\", message: messageOf(res.error) };\n case AIErrorCode.FeatureClosed:\n return {\n ok: false,\n kind: \"refused\",\n message: tr(\n {\n en: \"AI generation of {what} is closed right now.\",\n ru: \"ИИ-генерация {what} сейчас закрыта.\",\n },\n { what: tr(what) },\n ),\n };\n case AIErrorCode.Unavailable:\n case AIErrorCode.Disabled:\n case AIErrorCode.FeatureNotFound:\n case AIErrorCode.FeatureDisabled:\n case AIErrorCode.NotAvailableForPlayer:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"AI is unavailable right now.\",\n ru: \"ИИ сейчас недоступен.\",\n }),\n };\n default:\n return {\n ok: false,\n kind: \"refused\",\n message: code\n ? messageOf(res.error)\n : tr({\n en: \"Could not start the generation.\",\n ru: \"Не удалось запустить генерацию.\",\n }),\n };\n }\n}\n\n/** Недождавшаяся задача: сохраняется до результата, переживает перезагрузку. */\ninterface PendingJob<Extra> {\n id: string;\n extra: Extra;\n}\n\nexport function createSpecAI<Req, S, Extra>(\n client: IDosGamesClient,\n kind: SpecAIKind<Req, S, Extra>,\n opts: SpecAIOptions & {\n /** Старый формат сохранённой задачи (мир: { id, size }) — читать и его. */\n readPending?: (raw: Record<string, unknown>) => PendingJob<Extra> | null;\n writePending?: (job: PendingJob<Extra>) => unknown;\n } = {},\n): SpecAI<Req, S> {\n const featureKey = opts.featureKey ?? kind.featureKey;\n const pollMs = opts.pollMs ?? 4000;\n const timeoutMs = opts.timeoutMs ?? 10 * 60_000;\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const sleep = opts.sleep ?? abortableSleep;\n const pendingKey = `voxelcraft:${kind.pendingName}:${client.titleID}`;\n const T = kind.texts;\n let feature: SpecAIFeature | null = null;\n\n const savePending = (job: PendingJob<Extra>) => {\n try {\n storage?.setItem(\n pendingKey,\n JSON.stringify(opts.writePending ? opts.writePending(job) : job),\n );\n } catch {\n /* без хранилища задача просто не переживёт перезагрузку */\n }\n };\n const loadPending = (): PendingJob<Extra> | null => {\n try {\n const raw = storage?.getItem(pendingKey);\n const job = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;\n if (!job || typeof job.id !== \"string\") return null;\n if (opts.readPending) return opts.readPending(job);\n return \"extra\" in job ? (job as unknown as PendingJob<Extra>) : null;\n } catch {\n return null;\n }\n };\n const clearPending = () => {\n try {\n storage?.removeItem(pendingKey);\n } catch {\n /* нечего чистить */\n }\n };\n\n async function status(): Promise<SpecAIStatus> {\n const res = await client.ai.getDefinitions();\n if (!res.ok)\n return {\n available: false,\n reason:\n res.reason === \"unauthorized\"\n ? tr({\n en: \"sign in to build with AI\",\n ru: \"войдите, чтобы строить с ИИ\",\n })\n : tr({\n en: \"AI is unavailable right now\",\n ru: \"ИИ сейчас недоступен\",\n }),\n };\n if (res.data.Enabled === false)\n return {\n available: false,\n reason: tr({\n en: \"AI is turned off in this game\",\n ru: \"ИИ выключен в этой игре\",\n }),\n };\n const f = res.data.Features?.[featureKey];\n if (!f || (f.Modality && f.Modality !== \"Text\"))\n return {\n available: false,\n reason: tr(\n {\n en: \"AI generation of {what} is not set up\",\n ru: \"ИИ-генерация {what} не настроена\",\n },\n { what: tr(T.what) },\n ),\n };\n feature = toFeature(f);\n return { available: true, feature };\n }\n\n /** Опрос асинхронной задачи, пока игрок ждёт. */\n async function waitFor(\n first: AIGenerationView,\n signal: AbortSignal,\n onProgress: SpecAIProgress,\n ): Promise<AIGenerationView | Refusal> {\n const deadline = Date.now() + timeoutMs;\n let view = first;\n while (!client.ai.isTerminal(view.Status)) {\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n if (Date.now() > deadline)\n return { ok: false, kind: \"connection\", message: tr(T.slow) };\n onProgress(view.Progress ?? null, tr(T.working));\n await sleep(pollMs, signal);\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n const next = await client.ai.getText(view.GenerationID);\n if (!next.ok) {\n if (next.reason === \"throttled\" || next.reason === \"connection\")\n continue;\n if (parseAIErrorCode(next.error) === AIErrorCode.GenerationNotFound)\n clearPending();\n return refusal(next, T.login, T.what);\n }\n view = next.data;\n }\n return view;\n }\n\n /** Довести генерацию до рецепта: дождаться и разобрать. */\n async function settle(\n first: AIGenerationView,\n extra: Extra,\n signal: AbortSignal,\n onProgress: SpecAIProgress,\n ): Promise<SpecAIResult<S>> {\n let view = first;\n if (!client.ai.isTerminal(view.Status)) {\n savePending({ id: view.GenerationID, extra });\n const waited = await waitFor(view, signal, onProgress);\n if (\"ok\" in waited) return waited; // отменено / таймаут / отказ — задача остаётся сохранённой\n view = waited;\n }\n clearPending();\n if (view.Status !== \"completed\")\n return {\n ok: false,\n kind: \"failed\",\n message: view.Error?.Message || tr(T.failed),\n refunded: view.PlayerRefunded === true,\n };\n const parsed = kind.parse(view.OutputText ?? \"\", extra);\n if (!parsed.ok)\n return parsed.truncated\n ? { ok: false, kind: \"truncated\", message: tr(T.truncated) }\n : { ok: false, kind: \"invalid\", message: tr(T.invalid) };\n return parsed;\n }\n\n async function generate(\n req: Req,\n { signal, onProgress }: { signal: AbortSignal; onProgress: SpecAIProgress },\n ): Promise<SpecAIResult<S>> {\n if (!feature) {\n const s = await status();\n if (!s.available)\n return { ok: false, kind: \"refused\", message: s.reason };\n }\n const f = feature!; // status() выставил её или вернул отказ выше\n const input: AITextInput = {\n Prompt: kind.buildPrompt(req, f.maxPromptChars),\n };\n const images = kind.images(req);\n if (images.length && f.allowImages)\n input.Images = images\n .slice(0, f.maxImages)\n .map((im) => ({ Base64: im.dataUrl, ContentType: im.contentType }));\n\n // Один ключ на нажатие: повтор после обрыва связи — та же генерация, без второго списания.\n const key = newKey();\n onProgress(\n null,\n tr({ en: \"Sending the request to AI…\", ru: \"Отправляю запрос ИИ…\" }),\n );\n let res = await client.ai.generateText(featureKey, input, {\n relatedEntityID: key,\n });\n if (!res.ok && res.reason === \"connection\" && !signal.aborted) {\n onProgress(\n null,\n tr({\n en: \"Connection dropped, retrying…\",\n ru: \"Связь прервалась, повторяю…\",\n }),\n );\n res = await client.ai.generateText(featureKey, input, {\n relatedEntityID: key,\n });\n }\n if (!res.ok) {\n const code = parseAIErrorCode(res.error);\n if (\n code === AIErrorCode.FeatureNotFound ||\n code === AIErrorCode.FeatureDisabled\n )\n feature = null;\n return refusal(res, T.login, T.what);\n }\n return settle(res.data, kind.extra(req), signal, onProgress);\n }\n\n async function resume({\n signal,\n onProgress,\n }: {\n signal: AbortSignal;\n onProgress: SpecAIProgress;\n }): Promise<SpecAIResult<S> | null> {\n const job = loadPending();\n if (!job) return null;\n onProgress(null, tr(T.resuming));\n const res = await client.ai.getText(job.id);\n if (!res.ok) {\n if (res.reason === \"connection\" || res.reason === \"throttled\")\n return null; // попробуем в другой раз\n clearPending(); // задачи больше нет (или она чужая) — не спрашивать о ней снова\n return null;\n }\n return settle(res.data, job.extra, signal, onProgress);\n }\n\n return { status, generate, resume };\n}\n"
110
+ "content": "// Общая ИИ-генерация РЕЦЕПТА поверх client.ai (@idosgames/core): мир, персонаж, облик предмета —\n// одна и та же механика, разные фича, промпт и разбор ответа. Правила — по скиллу\n// ai-generation-system:\n//\n// - клиент называет только фичу: модель, системный промпт и цена — в конфиге тайтла;\n// - отказ (ok: false, \"AI_CODE: сообщение\") и провал генерации (Status: \"failed\") — разные вещи;\n// - у generate нет авто-ретрая транспорта: при обрыве связи повторяем САМИ и с тем же\n// relatedEntityID — сервер вернёт ту же генерацию и второй раз не спишет;\n// - асинхронную задачу опрашиваем сами, пока игрок ждёт; закрыл экран — задача дорабатывает на\n// сервере, её GenerationID сохраняется, и resume() дождётся результата в следующий раз.\n// От @idosgames/core — только типы: сам клиент модулю отдаёт хост (ctx.client), runtime-код core\n// модулю не нужен. Поэтому коды отказов ниже — литералами, зеркалом AIErrorCode из core.\nimport type {\n AIGenerationView,\n AIPublicFeature,\n AITextInput,\n IDosGamesClient,\n OperationResult,\n} from \"@idosgames/core\";\nimport { errorText, payWithCredits } from \"@idosgames/react/ui\";\nimport { tr, type Loc } from \"../i18n\";\nimport type {\n WorldAIFailure,\n WorldAIFeature,\n WorldAIProgress,\n WorldAIStatus,\n} from \"./types\";\n\ntype Failure = Extract<OperationResult<unknown>, { ok: false }>;\n\n/** Коды отказов AI-модуля (AIErrorCode в @idosgames/core; сервер — InAppAIErrors). */\nconst AIErrorCode = {\n Disabled: \"AI_DISABLED\",\n FeatureNotFound: \"AI_FEATURE_NOT_FOUND\",\n FeatureDisabled: \"AI_FEATURE_DISABLED\",\n NotAvailableForPlayer: \"AI_NOT_AVAILABLE_FOR_PLAYER\",\n FeatureClosed: \"AI_FEATURE_CLOSED\",\n LimitReached: \"AI_LIMIT_REACHED\",\n Cooldown: \"AI_COOLDOWN\",\n BadInput: \"AI_BAD_INPUT\",\n PromptRejected: \"AI_PROMPT_REJECTED\",\n Unavailable: \"AI_UNAVAILABLE\",\n GenerationNotFound: \"AI_GENERATION_NOT_FOUND\",\n} as const;\n\n/** Код из отказа \"AI_COOLDOWN: …\" (как parseAIErrorCode в core); null — отказ без кода. */\nexport function parseAIErrorCode(\n error: string | null | undefined,\n): string | null {\n if (!error) return null;\n return /\\b(AI_[A-Z_]+)\\s*:/.exec(error)?.[1] ?? null;\n}\n\nexport type SpecAIFeature = WorldAIFeature;\nexport type SpecAIStatus = WorldAIStatus;\nexport type SpecAIProgress = WorldAIProgress;\n\nexport type SpecAIResult<S> =\n | { ok: true; spec: S; warnings: string[] }\n | { ok: false; kind: WorldAIFailure; message: string; refunded?: boolean };\n\n/** Разбор текста ответа модели. */\nexport type SpecParse<S> =\n | { ok: true; spec: S; warnings: string[] }\n | { ok: false; truncated?: boolean };\n\nexport interface SpecAIImage {\n dataUrl: string;\n contentType: string;\n}\n\n/** Что отличает один вид генерации от другого. */\nexport interface SpecAIKind<Req, S, Extra = null> {\n featureKey: string;\n /** Суффикс ключа незаконченной задачи в localStorage. */\n pendingName: string;\n buildPrompt(req: Req, maxPromptChars: number | null): string;\n images(req: Req): SpecAIImage[];\n /** То, что надо помнить вместе с задачей до ответа (у мира — размер). */\n extra(req: Req): Extra;\n /** Ответ модели → рецепт (extra — то, что запомнили при запуске). */\n parse(text: string, extra: Extra): SpecParse<S>;\n /** Тексты для игрока. */\n texts: {\n /** «миры», «персонажей», «предметы» — «ИИ-генерация … не настроена». */\n what: Loc;\n /** «ИИ строит мир…» */\n working: Loc;\n /** «Войдите в игру, чтобы строить миры с ИИ.» */\n login: Loc;\n /** «ИИ не смог построить мир.» */\n failed: Loc;\n /** «ИИ ответил не рецептом мира.» */\n invalid: Loc;\n /** «Ответ ИИ оборван — мир получился слишком длинным.» */\n truncated: Loc;\n /** «Дожидаюсь мира с прошлого раза…» */\n resuming: Loc;\n /** «ИИ отвечает слишком долго. Откройте «Создать мир» позже — результат дождётся вас.» */\n slow: Loc;\n };\n}\n\nexport interface SpecAIOptions {\n featureKey?: string;\n pollMs?: number;\n timeoutMs?: number;\n /** localStorage по умолчанию; null — не сохранять задачу между запусками. */\n storage?: Pick<Storage, \"getItem\" | \"setItem\" | \"removeItem\"> | null;\n sleep?: (ms: number, signal: AbortSignal) => Promise<void>;\n}\n\nexport interface SpecAI<Req, S> {\n status(): Promise<SpecAIStatus>;\n generate(\n req: Req,\n opts: { signal: AbortSignal; onProgress: SpecAIProgress },\n ): Promise<SpecAIResult<S>>;\n /** Недождавшаяся задача с прошлого раза, если есть: дождаться её результата. */\n resume(opts: {\n signal: AbortSignal;\n onProgress: SpecAIProgress;\n }): Promise<SpecAIResult<S> | null>;\n}\n\nconst DEFAULT_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\"];\n\nfunction defaultStorage(): SpecAIOptions[\"storage\"] {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null; // хранилище запрещено (фрейм, настройки браузера)\n }\n}\n\nfunction abortableSleep(ms: number, signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n const t = setTimeout(resolve, ms);\n signal.addEventListener(\"abort\", () => {\n clearTimeout(t);\n resolve();\n });\n });\n}\n\nfunction newKey(): string {\n return typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID()\n : Date.now().toString(36) + \"-\" + Math.random().toString(36).slice(2, 12);\n}\n\n/** «10 GEM» из первой опции цены; null — бесплатно. */\nexport function priceLabel(\n options: AIPublicFeature[\"PriceOptions\"],\n): string | null {\n const first = options ? Object.values(options)[0] : undefined;\n if (!first) return null;\n if (first.Name) return first.Name;\n const parts = (first.Cost?.Standard?.Entries ?? [])\n .map((e) => `${e.Amount ?? \"\"} ${e.CurrencyID ?? e.ItemID ?? \"\"}`.trim())\n .filter(Boolean);\n return parts.length ? parts.join(\" + \") : null;\n}\n\nfunction toFeature(f: AIPublicFeature): SpecAIFeature {\n return {\n allowImages: f.AllowImages === true,\n maxImageBytes: f.MaxInputImageBytes || null,\n imageTypes: f.AllowedImageTypes?.length\n ? f.AllowedImageTypes\n : DEFAULT_TYPES,\n async: f.Async === true,\n priceLabel: priceLabel(f.PriceOptions),\n maxPromptChars: f.MaxPromptChars || null,\n // у фичи по умолчанию одна картинка; потолка платформы нет — сколько разрешил издатель\n maxImages: Math.max(1, f.MaxInputImages || 1),\n };\n}\n\n/** Безопасное сообщение из отказа \"AI_CODE: сообщение\". */\nfunction messageOf(error: string): string {\n const m = /\\bAI_[A-Z_]+\\s*:\\s*([\\s\\S]*)$/.exec(error);\n return (m?.[1] ?? error).trim();\n}\n\ntype Refusal = Extract<SpecAIResult<unknown>, { ok: false }>;\n\n/** Отказ сервера (ничего не сгенерировано и не списано) — в понятный игроку текст. */\nfunction refusal(res: Failure, login: Loc, what: Loc): Refusal {\n if (res.reason === \"connection\")\n return {\n ok: false,\n kind: \"connection\",\n message: tr({\n en: \"No connection to the server. Try again.\",\n ru: \"Нет связи с сервером. Попробуйте ещё раз.\",\n }),\n };\n if (res.reason === \"unauthorized\")\n return { ok: false, kind: \"refused\", message: tr(login) };\n if (res.reason === \"throttled\")\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"A generation is already running — wait for it.\",\n ru: \"Генерация уже идёт — дождитесь её.\",\n }),\n };\n // Отказы оплаты Aspis — словами кита, как во всех системах.\n if (/^CREDITS_|^NOT_ENOUGH_CREDITS$/.test(res.error))\n return { ok: false, kind: \"refused\", message: errorText(res.error) };\n const code = parseAIErrorCode(res.error);\n switch (code) {\n case AIErrorCode.LimitReached:\n case AIErrorCode.Cooldown:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"The AI generation limit is used up for now. Come back later.\",\n ru: \"Лимит ИИ-генераций на сейчас исчерпан. Загляните позже.\",\n }),\n };\n case AIErrorCode.PromptRejected:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"The description did not pass the check. Rephrase it.\",\n ru: \"Описание не прошло проверку. Переформулируйте его.\",\n }),\n };\n case AIErrorCode.BadInput:\n return { ok: false, kind: \"refused\", message: messageOf(res.error) };\n case AIErrorCode.FeatureClosed:\n return {\n ok: false,\n kind: \"refused\",\n message: tr(\n {\n en: \"AI generation of {what} is closed right now.\",\n ru: \"ИИ-генерация {what} сейчас закрыта.\",\n },\n { what: tr(what) },\n ),\n };\n case AIErrorCode.Unavailable:\n case AIErrorCode.Disabled:\n case AIErrorCode.FeatureNotFound:\n case AIErrorCode.FeatureDisabled:\n case AIErrorCode.NotAvailableForPlayer:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"AI is unavailable right now.\",\n ru: \"ИИ сейчас недоступен.\",\n }),\n };\n default:\n return {\n ok: false,\n kind: \"refused\",\n message: code\n ? messageOf(res.error)\n : tr({\n en: \"Could not start the generation.\",\n ru: \"Не удалось запустить генерацию.\",\n }),\n };\n }\n}\n\n/** Недождавшаяся задача: сохраняется до результата, переживает перезагрузку. */\ninterface PendingJob<Extra> {\n id: string;\n extra: Extra;\n}\n\nexport function createSpecAI<Req, S, Extra>(\n client: IDosGamesClient,\n kind: SpecAIKind<Req, S, Extra>,\n opts: SpecAIOptions & {\n /** Старый формат сохранённой задачи (мир: { id, size }) — читать и его. */\n readPending?: (raw: Record<string, unknown>) => PendingJob<Extra> | null;\n writePending?: (job: PendingJob<Extra>) => unknown;\n } = {},\n): SpecAI<Req, S> {\n const featureKey = opts.featureKey ?? kind.featureKey;\n const pollMs = opts.pollMs ?? 4000;\n const timeoutMs = opts.timeoutMs ?? 10 * 60_000;\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const sleep = opts.sleep ?? abortableSleep;\n const pendingKey = `voxelcraft:${kind.pendingName}:${client.titleID}`;\n const T = kind.texts;\n let feature: SpecAIFeature | null = null;\n\n const savePending = (job: PendingJob<Extra>) => {\n try {\n storage?.setItem(\n pendingKey,\n JSON.stringify(opts.writePending ? opts.writePending(job) : job),\n );\n } catch {\n /* без хранилища задача просто не переживёт перезагрузку */\n }\n };\n const loadPending = (): PendingJob<Extra> | null => {\n try {\n const raw = storage?.getItem(pendingKey);\n const job = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;\n if (!job || typeof job.id !== \"string\") return null;\n if (opts.readPending) return opts.readPending(job);\n return \"extra\" in job ? (job as unknown as PendingJob<Extra>) : null;\n } catch {\n return null;\n }\n };\n const clearPending = () => {\n try {\n storage?.removeItem(pendingKey);\n } catch {\n /* нечего чистить */\n }\n };\n\n async function status(): Promise<SpecAIStatus> {\n const res = await client.ai.getDefinitions();\n if (!res.ok)\n return {\n available: false,\n reason:\n res.reason === \"unauthorized\"\n ? tr({\n en: \"sign in to build with AI\",\n ru: \"войдите, чтобы строить с ИИ\",\n })\n : tr({\n en: \"AI is unavailable right now\",\n ru: \"ИИ сейчас недоступен\",\n }),\n };\n if (res.data.Enabled === false)\n return {\n available: false,\n reason: tr({\n en: \"AI is turned off in this game\",\n ru: \"ИИ выключен в этой игре\",\n }),\n };\n const f = res.data.Features?.[featureKey];\n if (!f || (f.Modality && f.Modality !== \"Text\"))\n return {\n available: false,\n reason: tr(\n {\n en: \"AI generation of {what} is not set up\",\n ru: \"ИИ-генерация {what} не настроена\",\n },\n { what: tr(T.what) },\n ),\n };\n feature = toFeature(f);\n return { available: true, feature };\n }\n\n /** Опрос асинхронной задачи, пока игрок ждёт. */\n async function waitFor(\n first: AIGenerationView,\n signal: AbortSignal,\n onProgress: SpecAIProgress,\n ): Promise<AIGenerationView | Refusal> {\n const deadline = Date.now() + timeoutMs;\n let view = first;\n while (!client.ai.isTerminal(view.Status)) {\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n if (Date.now() > deadline)\n return { ok: false, kind: \"connection\", message: tr(T.slow) };\n onProgress(view.Progress ?? null, tr(T.working));\n await sleep(pollMs, signal);\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n const next = await client.ai.getText(view.GenerationID);\n if (!next.ok) {\n if (next.reason === \"throttled\" || next.reason === \"connection\")\n continue;\n if (parseAIErrorCode(next.error) === AIErrorCode.GenerationNotFound)\n clearPending();\n return refusal(next, T.login, T.what);\n }\n view = next.data;\n }\n return view;\n }\n\n /** Довести генерацию до рецепта: дождаться и разобрать. */\n async function settle(\n first: AIGenerationView,\n extra: Extra,\n signal: AbortSignal,\n onProgress: SpecAIProgress,\n ): Promise<SpecAIResult<S>> {\n let view = first;\n if (!client.ai.isTerminal(view.Status)) {\n savePending({ id: view.GenerationID, extra });\n const waited = await waitFor(view, signal, onProgress);\n if (\"ok\" in waited) return waited; // отменено / таймаут / отказ — задача остаётся сохранённой\n view = waited;\n }\n clearPending();\n if (view.Status !== \"completed\")\n return {\n ok: false,\n kind: \"failed\",\n message: view.Error?.Message || tr(T.failed),\n refunded: view.PlayerRefunded === true,\n };\n const parsed = kind.parse(view.OutputText ?? \"\", extra);\n if (!parsed.ok)\n return parsed.truncated\n ? { ok: false, kind: \"truncated\", message: tr(T.truncated) }\n : { ok: false, kind: \"invalid\", message: tr(T.invalid) };\n return parsed;\n }\n\n async function generate(\n req: Req,\n { signal, onProgress }: { signal: AbortSignal; onProgress: SpecAIProgress },\n ): Promise<SpecAIResult<S>> {\n if (!feature) {\n const s = await status();\n if (!s.available)\n return { ok: false, kind: \"refused\", message: s.reason };\n }\n const f = feature!; // status() выставил её или вернул отказ выше\n const input: AITextInput = {\n Prompt: kind.buildPrompt(req, f.maxPromptChars),\n };\n const images = kind.images(req);\n if (images.length && f.allowImages)\n input.Images = images\n .slice(0, f.maxImages)\n .map((im) => ({ Base64: im.dataUrl, ContentType: im.contentType }));\n\n // Один ключ на нажатие: повтор после обрыва связи — та же генерация, без второго списания.\n const key = newKey();\n onProgress(\n null,\n tr({ en: \"Sending the request to AI…\", ru: \"Отправляю запрос ИИ…\" }),\n );\n // Цена в Aspis: первый вызов только просит игрока подтвердить на idosgames.com (окно — кит), списание —\n // только за готовую генерацию. Ключ тот же у обоих вызовов.\n const start = () =>\n payWithCredits(\n client,\n (creditsConfirmationID) =>\n client.ai.generateText(featureKey, input, {\n relatedEntityID: key,\n creditsConfirmationID,\n }),\n { signal },\n );\n let res = await start();\n if (!res.ok && res.reason === \"connection\" && !signal.aborted) {\n onProgress(\n null,\n tr({\n en: \"Connection dropped, retrying…\",\n ru: \"Связь прервалась, повторяю…\",\n }),\n );\n res = await start();\n }\n if (!res.ok) {\n const code = parseAIErrorCode(res.error);\n if (\n code === AIErrorCode.FeatureNotFound ||\n code === AIErrorCode.FeatureDisabled\n )\n feature = null;\n return refusal(res, T.login, T.what);\n }\n return settle(res.data, kind.extra(req), signal, onProgress);\n }\n\n async function resume({\n signal,\n onProgress,\n }: {\n signal: AbortSignal;\n onProgress: SpecAIProgress;\n }): Promise<SpecAIResult<S> | null> {\n const job = loadPending();\n if (!job) return null;\n onProgress(null, tr(T.resuming));\n const res = await client.ai.getText(job.id);\n if (!res.ok) {\n if (res.reason === \"connection\" || res.reason === \"throttled\")\n return null; // попробуем в другой раз\n clearPending(); // задачи больше нет (или она чужая) — не спрашивать о ней снова\n return null;\n }\n return settle(res.data, job.extra, signal, onProgress);\n }\n\n return { status, generate, resume };\n}\n"
111
111
  },
112
112
  {
113
113
  "path": "ai/types.ts",
@@ -39,12 +39,12 @@
39
39
  "version": "0.1.3"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.25.0",
43
- "@idosgames/module-sdk": "0.13.2",
44
- "@idosgames/react": "0.6.3",
42
+ "@idosgames/core": "0.26.1",
43
+ "@idosgames/module-sdk": "0.13.3",
44
+ "@idosgames/react": "0.7.1",
45
45
  "react": "19.2.7"
46
46
  },
47
- "contentHash": "ba3020258426f2c8b890d16cd169bffb4e96a4fc61d8a312c24d706ec9eac315",
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 => ERRORS[e] ?? e;\n"
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,7 +1,7 @@
1
1
  {
2
2
  "name": "currency-system",
3
3
  "description": "Convert between currencies in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.currency (CurrencyService): virtual-currency to virtual-currency (VC↔VC) conversion, and crypto-source conversion (crypto→VC or crypto→crypto). This is also the canonical home for the SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry cost-and-reward types used by every other module (Store, Character, Craft, Lootbox, Blockchain, …). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a currency-exchange screen, gold-to-gems conversion, crypto conversion, or otherwise touches client.currency, CurrencyService, ConvertResponse, CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or ResourceEntry — even if they don't name the module explicitly.",
4
- "content": "---\nname: currency-system\ndescription: >-\n Convert between currencies in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.currency (CurrencyService): virtual-currency to\n virtual-currency (VC↔VC) conversion, and crypto-source conversion\n (crypto→VC or crypto→crypto). This is also the canonical home for the\n SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry\n cost-and-reward types used by every other module (Store, Character, Craft,\n Lootbox, Blockchain, …). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n currency-exchange screen, gold-to-gems conversion, crypto conversion, or\n otherwise touches client.currency, CurrencyService, ConvertResponse,\n CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or\n ResourceEntry — even if they don't name the module explicitly.\n---\n\n# Currency system (iDosGames TS SDK)\n\nThe Currency module is small — two methods — but it's the module every other\nsystem rides on: it's the reference implementation for converting one balance\ninto another, and it's the canonical home for the **shared cost/reward\nprimitives** (`ResourceConsume`, `ResourceGrant`, `ResourceOperation`,\n`ResourceEntry`) that Store, Character, Craft, Lootbox, Blockchain, and others\nall use to describe \"this action costs X and grants Y.\" Read this skill once\nand the resource shapes in every other module's docs make sense by reference.\n\nEverything is **server-authoritative**: the client asks the backend to\nconvert, the backend validates status/rate/fee/limits and debits/credits, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate balances yourself, and you never precompute the rate, fee, or\nrounding client-side — the backend owns all of it.\n\nThis skill is for **using** the production `CurrencyService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(disabled conversion, unlisted target, currency under maintenance, daily\nlimit, insufficient funds) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Two currency kinds\n\n- **Virtual currency (VC)** — title-defined, integer balances (gold, gems,\n energy). Config: `VirtualCurrencyDefinition`.\n- **Crypto currency** — on-chain-backed, decimal balances (ETH, USDT, …).\n Config: `CryptoCurrencyDefinition`. Crypto balances/deposits/withdrawals are\n otherwise the Blockchain module's territory — see the blockchain-system\n skill; Currency only covers converting a crypto balance you already hold.\n\nBoth currency kinds share a `CurrencyType` tag (`\"Virtual\"` | `\"Crypto\"`) used\nthroughout requests/responses to disambiguate `CurrencyID`s that could\notherwise collide.\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 currency = client.currency; // the CurrencyService\n```\n\nEvery currency method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the 600ms throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Conversion from\n'X' is disabled\", \"is under maintenance\", \"is deprecated and cannot receive\nnew credits\", \"Amount below pair minimum\", \"Per-pair daily limit exceeded\",\ninsufficient balance).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------- |\n| `convert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | VC↔VC conversion only. Integer amounts. | `ConvertResponse` |\n| `cryptoConvert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | Crypto-**source** conversion (crypto→VC or crypto→crypto). Decimal amounts. | `CryptoConvertResponse` |\n\n`sourceAmount` for `convert` is a `number` (truncated to an integer via\n`Math.trunc` before sending — VC balances are integer). For `cryptoConvert` it\nis `Decimal | string | number` (a `decimal.js` `Decimal`, a numeric string, or\na number) — pass a string or `Decimal` for anything beyond safe-integer/float\nprecision; the SDK depends on `decimal.js`. Each method validates locally\nbefore any network call and rejects mismatched pairs:\n\n- `convert` rejects if either side is `CurrencyType.Crypto` — \"Convert\n supports VC↔VC only. Use cryptoConvert for crypto.\"\n- `cryptoConvert` rejects if **both** sides are `CurrencyType.Virtual` —\n \"cryptoConvert requires at least one crypto side. Use convert for VC↔VC.\"\n- Both reject source === target, empty IDs, non-positive amounts.\n- `cryptoConvert` additionally rejects a non-integer amount when the source\n side is `Virtual` (VC amounts must be whole) — \"Virtual currency source\n amount must be integer.\"\n\nOne server-side constraint the SDK preflight does **not** catch: the backend's\n`CryptoConvert` endpoint requires the **source** to be `Crypto`, and\n`Virtual→Crypto` is not supported by any endpoint (`Convert` explicitly fails\nVirtual→Crypto with \"Virtual→Crypto conversion is not supported.\"). So the\nonly valid pairings are `convert` for VC→VC and `cryptoConvert` for crypto→VC\n/ crypto→crypto; a Virtual-source `cryptoConvert` passes the local check but\ncomes back `reason: \"server\"` (\"CryptoConvert requires source to be Crypto.\nUse Convert for VC↔VC.\").\n\n`transactionID` is optional; omit it and the SDK generates a unique one per\ncall (`convert_<sourceType>_<sourceID>_to_<targetType>_<targetID>_<uuid>` /\n`crypto_convert_...`). The backend folds `TransactionID` into its idempotency\nkey (`CurrencyConvert:<key>` / `CurrencyCryptoConvert:<key>`, where `<key>` is\n`TransactionID` verbatim when you supply one), stored per `(userID, reason)`\nfor 7 days. **Retrying with the same `transactionID` is safe** — the server\ndetects the replay and returns the stored result instead of charging again.\nTwo calls with different (e.g. auto-generated) IDs are two real conversions.\n\nOn success, both methods **mirror the confirmed debit/credit into the cache\nand emit an event** — you don't apply anything by hand. Read updated balances\nstraight from the cache.\n\n### Non-obvious server behavior worth knowing before you build UI\n\n- **Rate resolution**: `Automatic` mode divides the source's `ValueInUSD` by\n the target's `ValueInUSD` (`rate = src.ValueInUSD / tgt.ValueInUSD`);\n `Manual` mode uses the fixed `Rate` pinned on that specific\n `ConversionTarget`. Which mode applies is a property of the **source**\n currency (`Conversion.RateMode`), not the pair.\n- **Fee comes off first, then the rate, then rounding**: `fee = sourceAmount *\nFee`; `net = sourceAmount - fee`; `output = net * rate`. Order matters for\n previews.\n- **`Fee` is a SHARE, not a percent**: `0.05` means 5%. This is the unit used\n across the whole platform (`RateSpec`), and the value is passed straight into\n `RateSpec.Rate` with no rescaling — there is no division by 100 anywhere. The\n field is deliberately not called `FeePercent`; the `Percent` suffix is banned\n platform-wide because it was the source of 0..1 vs 0..100 confusion. Show\n percents in a UI by multiplying by 100. A `Fee` of `1.0` is a deliberate ban\n on converting that currency, and is not clamped away.\n- **Rounding is always truncation toward zero, never nearest/ceiling.** VC↔VC\n truncates the final `output` to a `long`. Crypto-source conversions keep\n full decimal precision throughout _except_ when the target is Virtual, where\n the decimal `output` is floored (`Math.Floor`) to a `long`. A conversion\n whose result rounds to 0 (dust) is rejected rather than silently granting\n nothing.\n- **Currency status gates asymmetrically**: `Maintenance` blocks the currency\n on either side; `Deprecated` blocks it only as a **target** (can't receive\n new credits) — a deprecated currency can still be spent down as a _source_.\n- **Two independent limit layers can reject the same call**: the per-pair\n `ConversionTarget.MinAmount` / `MaxAmount` / `DailyLimit` (scoped to this\n exact source→target pair), and the source currency's own\n `Economy.MinBalance` / `MaxBalance` / `DailyEarnLimit` / `DailySpendLimit`\n (scoped to the whole currency, across every way it can change). Either can\n fail independently — don't assume passing one means the other passed.\n\n## Reading state and reacting to changes\n\n```ts\n// Virtual currency balance (integer):\nclient.data.user.getVirtualCurrencyAmount(\"coins\"); // number\n\n// Crypto currency balance (decimal-as-string):\nclient.data.user.getCryptoCurrencyAmount(\"eth\"); // string, e.g. \"0.05\"\n\n// Currency catalog (config). Populated by client.title.getCurrencyDefinitions()\n// (emits \"title:currencyDefinitionsReceived\"), with a fallback to the `Currency`\n// section of the full title public configuration if that's been loaded:\nconst defs = client.data.config.currencyDefinitions; // CurrencyDefinitions | undefined\ndefs?.VirtualCurrencies?.[\"coins\"]?.Conversion?.Targets;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `currency:converted` → `ConvertResponse`\n- `currency:cryptoConverted` → `CryptoConvertResponse`\n\nNeither `convert` nor `cryptoConvert` has its own coarse `user:currencyUpdated`\nevent — the balance change instead rides through the same resource pipeline\nevery other module uses. `convert` (VC↔VC) always applies through the integer\nresource pipeline, so it fires `user:virtualCurrencyUpdated` +\n`user:inventoryUpdated` (plus the umbrella `user:anyUpdated`) for both sides.\n`cryptoConvert` splits by side: a `Virtual` leg goes through the same\nresource pipeline (same events as above for that leg only); a `Crypto` leg\ngoes through a separate decimal patch that fires only\n`user:inventoryUpdated` + `user:anyUpdated` (no\n`user:virtualCurrencyUpdated`, since no VC balance changed). Listen at\nwhichever granularity suits your UI: the specific `currency:*` event for a\ntoast/confirmation, the coarse `user:*` event for a \"re-render balances\" hook.\n\n```ts\nconst off = client.on(\"currency:converted\", (r) => {\n console.log(\n `Spent ${r.SourceSpent} ${r.SourceID} -> got ${r.TargetCredited} ${r.TargetID}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Convert gold to gems (VC↔VC)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // 100 (integer, includes the fee)\nres.data.FeeAmount; // e.g. 10 (integer, source-currency units — 10% fee here)\nres.data.TargetCredited; // e.g. 45 (integer: (100 - 10) * 0.5, truncated)\nres.data.RateApplied; // decimal-as-string, e.g. \"0.5\"\n// Balances are already updated in the cache:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Convert a crypto balance to virtual currency\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\nimport Decimal from \"decimal.js\";\n\nconst res = await client.currency.cryptoConvert(\n CurrencyType.Crypto,\n \"usdt\",\n CurrencyType.Virtual,\n \"gems\",\n new Decimal(\"2.50\"), // decimal source amount\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // \"2.50\" (decimal string)\nres.data.TargetCredited; // \"500\" (decimal string; VC side is still integer-valued and floored)\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Read a cost/reward breakdown from another module's response\n\nCurrency doesn't return `ResourceOperation` itself (its responses are flat\n`ConvertResponse`/`CryptoConvertResponse`), but almost every other module's\nresponse embeds one under a `Resources` field — e.g. a Store purchase, a\nCharacter upgrade, a Blockchain deposit. Once you've read this skill you can\nread any of them the same way:\n\n```ts\nimport type { ResourceOperation } from \"@idosgames/core\";\n\nfunction summarize(op: ResourceOperation | null | undefined) {\n const spent = op?.Consume?.Standard?.Entries ?? [];\n const gained = op?.Grant?.Standard?.Entries ?? [];\n for (const e of spent)\n console.log(`-${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n for (const e of gained)\n console.log(`+${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n}\n```\n\n`Standard` is already the server-resolved final amount (premium\ndiscounts/bonuses folded in) — don't re-derive it from `PremiumDiscounts` /\n`PremiumTiers`. See [references/data-model.md](references/data-model.md) for\nthe full shape and every field.\n\n### Edge case: rejected pairing (client-side, no network call)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Crypto, // wrong method for a crypto side\n \"eth\",\n CurrencyType.Virtual,\n \"coins\",\n 1,\n);\n// res.ok === false, res.reason === \"client\" — rejected locally, no round-trip.\n// Use cryptoConvert instead.\n```\n\n### Edge case: not logged in\n\n```ts\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res.ok === false, res.reason === \"unauthorized\"\n```\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n client-side (the auto-generated `TransactionID`), so two separate calls are\n two real operations — a double-clicked \"Convert\" can charge twice. Disable\n the control while a call is in flight. (Firing the same endpoint again\n within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.)\n- **`convert` and `cryptoConvert` are not interchangeable.** `convert` is\n VC↔VC only (integer amounts); `cryptoConvert` requires at least one crypto\n side (decimal amounts) and rejects a VC↔VC pair. Both reject mismatched\n calls with `reason: \"client\"` before any network round-trip.\n- **Amount precision matters.** VC amounts are always integers — `convert`\n truncates via `Math.trunc`. Crypto amounts are decimal; pass a `Decimal` or\n numeric string for `cryptoConvert` rather than a JS `number` once you're\n near float precision limits (the SDK's own crypto math uses `decimal.js`\n throughout).\n- **Rounding always favors the house, never the player.** Both the VC↔VC and\n the crypto→VC paths truncate/floor the credited amount down — there is no\n \"round to nearest.\" A tiny source amount can legitimately convert to 0\n target units, which the backend rejects outright rather than granting a\n free-rounding credit.\n- **`RateApplied`/`FeeAmount` are informational, not something to\n precompute.** The backend enforces the title's configured\n `CurrencyConversion` rules (`Enabled`, `RateMode`, `Fee`, whitelisted\n `Targets` with their own `MinAmount`/`MaxAmount`/`DailyLimit`) — read the\n actual applied numbers off the response rather than estimating client-side.\n- **A conversion can fail on the currency's global limits even if the pair\n looks fine.** `Economy.MinBalance`/`MaxBalance`/`DailyEarnLimit`/\n `DailySpendLimit` apply on top of (and independently of) the pair-specific\n `MinAmount`/`MaxAmount`/`DailyLimit` — show whichever `error` string comes\n back rather than trying to pre-validate both layers yourself.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — the full\n`CurrencyDefinitions` config shape (virtual + crypto), the backend conversion\nformulas transcribed from source, and the complete, canonical documentation of\nthe shared `ResourceConsume` / `ResourceGrant` / `ResourceOperation` /\n`ResourceEntry` types used across the whole SDK. Read it before building\ncost/reward UI in any other module (Store offers, Character upgrades, Craft\nrecipes, Lootbox prices, Blockchain deposits/withdrawals all describe their\ncosts and payouts with these same shapes).\n",
4
+ "content": "---\nname: currency-system\ndescription: >-\n Convert between currencies in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.currency (CurrencyService): virtual-currency to\n virtual-currency (VC↔VC) conversion, and crypto-source conversion\n (crypto→VC or crypto→crypto). This is also the canonical home for the\n SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry\n cost-and-reward types used by every other module (Store, Character, Craft,\n Lootbox, Blockchain, …). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n currency-exchange screen, gold-to-gems conversion, crypto conversion, or\n otherwise touches client.currency, CurrencyService, ConvertResponse,\n CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or\n ResourceEntry — even if they don't name the module explicitly.\n---\n\n# Currency system (iDosGames TS SDK)\n\nThe Currency module is small — two methods — but it's the module every other\nsystem rides on: it's the reference implementation for converting one balance\ninto another, and it's the canonical home for the **shared cost/reward\nprimitives** (`ResourceConsume`, `ResourceGrant`, `ResourceOperation`,\n`ResourceEntry`) that Store, Character, Craft, Lootbox, Blockchain, and others\nall use to describe \"this action costs X and grants Y.\" Read this skill once\nand the resource shapes in every other module's docs make sense by reference.\n\nEverything is **server-authoritative**: the client asks the backend to\nconvert, the backend validates status/rate/fee/limits and debits/credits, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate balances yourself, and you never precompute the rate, fee, or\nrounding client-side — the backend owns all of it.\n\nThis skill is for **using** the production `CurrencyService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(disabled conversion, unlisted target, currency under maintenance, daily\nlimit, insufficient funds) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Two currency kinds\n\n- **Virtual currency (VC)** — title-defined, integer balances (gold, gems,\n energy). Config: `VirtualCurrencyDefinition`.\n- **Crypto currency** — on-chain-backed, decimal balances (ETH, USDT, …).\n Config: `CryptoCurrencyDefinition`. Crypto balances/deposits/withdrawals are\n otherwise the Blockchain module's territory — see the blockchain-system\n skill; Currency only covers converting a crypto balance you already hold.\n\nBoth currency kinds share a `CurrencyType` tag (`\"Virtual\"` | `\"Crypto\"`) used\nthroughout requests/responses to disambiguate `CurrencyID`s that could\notherwise collide.\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 currency = client.currency; // the CurrencyService\n```\n\nEvery currency method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the 600ms throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Conversion from\n'X' is disabled\", \"is under maintenance\", \"is deprecated and cannot receive\nnew credits\", \"Amount below pair minimum\", \"Per-pair daily limit exceeded\",\ninsufficient balance).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------- |\n| `convert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | VC↔VC conversion only. Integer amounts. | `ConvertResponse` |\n| `cryptoConvert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | Crypto-**source** conversion (crypto→VC or crypto→crypto). Decimal amounts. | `CryptoConvertResponse` |\n| `spend(spendID, { selectedOptionID?, relatedEntityID? }?)` | Pays for an action of the game (a revive, other upgrade cards) with the price of `Currency.Spends[spendID]`. Charges, grants nothing. | `SpendResponse` |\n\n`sourceAmount` for `convert` is a `number` (truncated to an integer via\n`Math.trunc` before sending — VC balances are integer). For `cryptoConvert` it\nis `Decimal | string | number` (a `decimal.js` `Decimal`, a numeric string, or\na number) — pass a string or `Decimal` for anything beyond safe-integer/float\nprecision; the SDK depends on `decimal.js`. Each method validates locally\nbefore any network call and rejects mismatched pairs:\n\n- `convert` rejects if either side is `CurrencyType.Crypto` — \"Convert\n supports VC↔VC only. Use cryptoConvert for crypto.\"\n- `cryptoConvert` rejects if **both** sides are `CurrencyType.Virtual` —\n \"cryptoConvert requires at least one crypto side. Use convert for VC↔VC.\"\n- Both reject source === target, empty IDs, non-positive amounts.\n- `cryptoConvert` additionally rejects a non-integer amount when the source\n side is `Virtual` (VC amounts must be whole) — \"Virtual currency source\n amount must be integer.\"\n\nOne server-side constraint the SDK preflight does **not** catch: the backend's\n`CryptoConvert` endpoint requires the **source** to be `Crypto`, and\n`Virtual→Crypto` is not supported by any endpoint (`Convert` explicitly fails\nVirtual→Crypto with \"Virtual→Crypto conversion is not supported.\"). So the\nonly valid pairings are `convert` for VC→VC and `cryptoConvert` for crypto→VC\n/ crypto→crypto; a Virtual-source `cryptoConvert` passes the local check but\ncomes back `reason: \"server\"` (\"CryptoConvert requires source to be Crypto.\nUse Convert for VC↔VC.\").\n\n`transactionID` is optional; omit it and the SDK generates a unique one per\ncall (`convert_<sourceType>_<sourceID>_to_<targetType>_<targetID>_<uuid>` /\n`crypto_convert_...`). The backend folds `TransactionID` into its idempotency\nkey (`CurrencyConvert:<key>` / `CurrencyCryptoConvert:<key>`, where `<key>` is\n`TransactionID` verbatim when you supply one), stored per `(userID, reason)`\nfor 7 days. **Retrying with the same `transactionID` is safe** — the server\ndetects the replay and returns the stored result instead of charging again.\nTwo calls with different (e.g. auto-generated) IDs are two real conversions.\n\nOn success, both methods **mirror the confirmed debit/credit into the cache\nand emit an event** — you don't apply anything by hand. Read updated balances\nstraight from the cache.\n\n### Non-obvious server behavior worth knowing before you build UI\n\n- **Rate resolution**: `Automatic` mode divides the source's `ValueInUSD` by\n the target's `ValueInUSD` (`rate = src.ValueInUSD / tgt.ValueInUSD`);\n `Manual` mode uses the fixed `Rate` pinned on that specific\n `ConversionTarget`. Which mode applies is a property of the **source**\n currency (`Conversion.RateMode`), not the pair.\n- **Fee comes off first, then the rate, then rounding**: `fee = sourceAmount *\nFee`; `net = sourceAmount - fee`; `output = net * rate`. Order matters for\n previews.\n- **`Fee` is a SHARE, not a percent**: `0.05` means 5%. This is the unit used\n across the whole platform (`RateSpec`), and the value is passed straight into\n `RateSpec.Rate` with no rescaling — there is no division by 100 anywhere. The\n field is deliberately not called `FeePercent`; the `Percent` suffix is banned\n platform-wide because it was the source of 0..1 vs 0..100 confusion. Show\n percents in a UI by multiplying by 100. A `Fee` of `1.0` is a deliberate ban\n on converting that currency, and is not clamped away.\n- **Rounding is always truncation toward zero, never nearest/ceiling.** VC↔VC\n truncates the final `output` to a `long`. Crypto-source conversions keep\n full decimal precision throughout _except_ when the target is Virtual, where\n the decimal `output` is floored (`Math.Floor`) to a `long`. A conversion\n whose result rounds to 0 (dust) is rejected rather than silently granting\n nothing.\n- **Currency status gates asymmetrically**: `Maintenance` blocks the currency\n on either side; `Deprecated` blocks it only as a **target** (can't receive\n new credits) — a deprecated currency can still be spent down as a _source_.\n- **Two independent limit layers can reject the same call**: the per-pair\n `ConversionTarget.MinAmount` / `MaxAmount` / `DailyLimit` (scoped to this\n exact source→target pair), and the source currency's own\n `Economy.MinBalance` / `MaxBalance` / `DailyEarnLimit` / `DailySpendLimit`\n (scoped to the whole currency, across every way it can change). Either can\n fail independently — don't assume passing one means the other passed.\n\n## Reading state and reacting to changes\n\n```ts\n// Virtual currency balance (integer):\nclient.data.user.getVirtualCurrencyAmount(\"coins\"); // number\n\n// Crypto currency balance (decimal-as-string):\nclient.data.user.getCryptoCurrencyAmount(\"eth\"); // string, e.g. \"0.05\"\n\n// Currency catalog (config). Populated by client.title.getCurrencyDefinitions()\n// (emits \"title:currencyDefinitionsReceived\"), with a fallback to the `Currency`\n// section of the full title public configuration if that's been loaded:\nconst defs = client.data.config.currencyDefinitions; // CurrencyDefinitions | undefined\ndefs?.VirtualCurrencies?.[\"coins\"]?.Conversion?.Targets;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `currency:converted` → `ConvertResponse`\n- `currency:cryptoConverted` → `CryptoConvertResponse`\n- `currency:spent` → `SpendResponse`\n\nNeither `convert` nor `cryptoConvert` has its own coarse `user:currencyUpdated`\nevent — the balance change instead rides through the same resource pipeline\nevery other module uses. `convert` (VC↔VC) always applies through the integer\nresource pipeline, so it fires `user:virtualCurrencyUpdated` +\n`user:inventoryUpdated` (plus the umbrella `user:anyUpdated`) for both sides.\n`cryptoConvert` splits by side: a `Virtual` leg goes through the same\nresource pipeline (same events as above for that leg only); a `Crypto` leg\ngoes through a separate decimal patch that fires only\n`user:inventoryUpdated` + `user:anyUpdated` (no\n`user:virtualCurrencyUpdated`, since no VC balance changed). Listen at\nwhichever granularity suits your UI: the specific `currency:*` event for a\ntoast/confirmation, the coarse `user:*` event for a \"re-render balances\" hook.\n\n```ts\nconst off = client.on(\"currency:converted\", (r) => {\n console.log(\n `Spent ${r.SourceSpent} ${r.SourceID} -> got ${r.TargetCredited} ${r.TargetID}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Pay for an action of the game (a revive, a reroll)\n\nThe title config lists the spends (`Currency.Spends`: id → price options). The client names the\nspend, never the amount, so a player cannot pay less or count a fake spend toward \"spend N\" quests.\nPrices are virtual currencies, items or rewarded-video credit; a store payment, Aspis (Credits) and\ncrypto are refused. Do the action only after `ok`. Show the button only when the spend exists, and\nread its price from the config rather than hard-coding it:\n\n```ts\nconst spends = client.data.config.getSection<CurrencyDefinitions>(\"Currency\")?.Spends;\nif (spends?.[\"revive\"]) {\n // the same key on a retry (a double tap, a timeout): the server answers paid without charging twice\n const res = await client.currency.spend(\"revive\", { relatedEntityID: `run-${runId}-revive` });\n if (res.ok) reviveHero();\n}\n```\n\nA store offer cannot do this: an offer must grant something.\n\n### Convert gold to gems (VC↔VC)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // 100 (integer, includes the fee)\nres.data.FeeAmount; // e.g. 10 (integer, source-currency units — 10% fee here)\nres.data.TargetCredited; // e.g. 45 (integer: (100 - 10) * 0.5, truncated)\nres.data.RateApplied; // decimal-as-string, e.g. \"0.5\"\n// Balances are already updated in the cache:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Convert a crypto balance to virtual currency\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\nimport Decimal from \"decimal.js\";\n\nconst res = await client.currency.cryptoConvert(\n CurrencyType.Crypto,\n \"usdt\",\n CurrencyType.Virtual,\n \"gems\",\n new Decimal(\"2.50\"), // decimal source amount\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // \"2.50\" (decimal string)\nres.data.TargetCredited; // \"500\" (decimal string; VC side is still integer-valued and floored)\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Read a cost/reward breakdown from another module's response\n\nCurrency doesn't return `ResourceOperation` itself (its responses are flat\n`ConvertResponse`/`CryptoConvertResponse`), but almost every other module's\nresponse embeds one under a `Resources` field — e.g. a Store purchase, a\nCharacter upgrade, a Blockchain deposit. Once you've read this skill you can\nread any of them the same way:\n\n```ts\nimport type { ResourceOperation } from \"@idosgames/core\";\n\nfunction summarize(op: ResourceOperation | null | undefined) {\n const spent = op?.Consume?.Standard?.Entries ?? [];\n const gained = op?.Grant?.Standard?.Entries ?? [];\n for (const e of spent)\n console.log(`-${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n for (const e of gained)\n console.log(`+${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n}\n```\n\n`Standard` is already the server-resolved final amount (premium\ndiscounts/bonuses folded in) — don't re-derive it from `PremiumDiscounts` /\n`PremiumTiers`. See [references/data-model.md](references/data-model.md) for\nthe full shape and every field.\n\n### Edge case: rejected pairing (client-side, no network call)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Crypto, // wrong method for a crypto side\n \"eth\",\n CurrencyType.Virtual,\n \"coins\",\n 1,\n);\n// res.ok === false, res.reason === \"client\" — rejected locally, no round-trip.\n// Use cryptoConvert instead.\n```\n\n### Edge case: not logged in\n\n```ts\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res.ok === false, res.reason === \"unauthorized\"\n```\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n client-side (the auto-generated `TransactionID`), so two separate calls are\n two real operations — a double-clicked \"Convert\" can charge twice. Disable\n the control while a call is in flight. (Firing the same endpoint again\n within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.)\n- **`convert` and `cryptoConvert` are not interchangeable.** `convert` is\n VC↔VC only (integer amounts); `cryptoConvert` requires at least one crypto\n side (decimal amounts) and rejects a VC↔VC pair. Both reject mismatched\n calls with `reason: \"client\"` before any network round-trip.\n- **Amount precision matters.** VC amounts are always integers — `convert`\n truncates via `Math.trunc`. Crypto amounts are decimal; pass a `Decimal` or\n numeric string for `cryptoConvert` rather than a JS `number` once you're\n near float precision limits (the SDK's own crypto math uses `decimal.js`\n throughout).\n- **Rounding always favors the house, never the player.** Both the VC↔VC and\n the crypto→VC paths truncate/floor the credited amount down — there is no\n \"round to nearest.\" A tiny source amount can legitimately convert to 0\n target units, which the backend rejects outright rather than granting a\n free-rounding credit.\n- **`RateApplied`/`FeeAmount` are informational, not something to\n precompute.** The backend enforces the title's configured\n `CurrencyConversion` rules (`Enabled`, `RateMode`, `Fee`, whitelisted\n `Targets` with their own `MinAmount`/`MaxAmount`/`DailyLimit`) — read the\n actual applied numbers off the response rather than estimating client-side.\n- **A conversion can fail on the currency's global limits even if the pair\n looks fine.** `Economy.MinBalance`/`MaxBalance`/`DailyEarnLimit`/\n `DailySpendLimit` apply on top of (and independently of) the pair-specific\n `MinAmount`/`MaxAmount`/`DailyLimit` — show whichever `error` string comes\n back rather than trying to pre-validate both layers yourself.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — the full\n`CurrencyDefinitions` config shape (virtual + crypto), the backend conversion\nformulas transcribed from source, and the complete, canonical documentation of\nthe shared `ResourceConsume` / `ResourceGrant` / `ResourceOperation` /\n`ResourceEntry` types used across the whole SDK. Read it before building\ncost/reward UI in any other module (Store offers, Character upgrades, Craft\nrecipes, Lootbox prices, Blockchain deposits/withdrawals all describe their\ncosts and payouts with these same shapes).\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-compose-modules",
3
3
  "description": "Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards, quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system, add a second game, switch between the lobby and game modes, share currency/inventory across modules, or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi, shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module).",
4
- "content": "---\nname: idosgames-compose-modules\ndescription: >-\n Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards,\n quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this\n whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system,\n add a second game, switch between the lobby and game modes, share currency/inventory across modules,\n or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi,\n shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared\n state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single\n module).\n---\n\n# Putting an app together: the base + modules\n\nEvery project is **the base + modules**. The base is sign-in and the lobby and comes from the SDK\n(`@idosgames/app-shell`: `createBaseModule`, `DefaultLoginScreen`, `DefaultPlayAccessScreen`); the project\nconfigures it in `src/app.config.ts` (where the player lands, the tab order, guest sign-in), `src/ui.config.ts`\n(the look) and `src/main.tsx`. Everything else is a module in `src/modules/<id>/`:\n\n- **systems** (`type: feature`) — one per player-facing module of the engine: store, premium (VIP),\n character, inventory, lootboxes, boosts, leaderboard, rewards (daily), quests, collections, craft,\n marketplace, deals, events, coop-events, season, friends, chat, invite, pvp, multiplayer, profile,\n tutorials, workshop, ai, mailbox. Each registers its screen as a **feature** → a tab of the lobby.\n The base installs only six of them into every new project (`inBase: true`: profile, inventory,\n invite, friends, quests, mailbox); every other system is installed only when the app needs it — a game\n brings the ones it lists in `uses`, and you install any other one as soon as the task needs what\n it `provides` (InstallModule / `get_module`) — the user does not have to name the module; reuse it\n instead of writing that feature again. Never install one the task does not need.\n- **games** (`type: template`) — board-game, idle-rpg, voxelcraft, mini-game, or the game's own. Each\n registers a **route** → a \"Play\" button in the lobby. A game that plugs into a system lists it in\n `uses` in its module.meta.json (`ctx.multiplayer` → `\"multiplayer\"`, `ctx.content` → `\"workshop\"`;\n `npm run check` enforces those two) — install those together with the game.\n\n## Register modules\n\n```ts\n// src/modules.ts — the platform regenerates it; by hand it is imports + the array\nexport const modules: Module[] = [\n storeModule, // systems: lobby tabs\n characterModule,\n leaderboardModule,\n boardGameModule, // games: \"Play\"\n idleRpgModule,\n];\n```\n\n`src/main.tsx` mounts `[createBaseModule(appConfig, …), ...modules]`. After sign-in the player lands in the game that\nlives inside the lobby, or in the lobby when the games take the whole screen (`src/app.config.ts`\n`start`: `\"game\"` — the default, `\"lobby\"`, or a game's mode id). \"Play\" switches the host to the\ngame's mode: only the active mode's scene is mounted and ticking; the rest are suspended (their RAF\nstops). This is why two different engines (Three + Phaser) live in one project — they never render\nat the same time. Two kinds of game (owner's decision 03.10.2026 — chosen per game, never for all):\n\n- `ctx.registerRoute({ …, inLobby: true })` — the game sits INSIDE the lobby (idle-rpg): the base\n keeps the balances on top and the tabs at the bottom (host panel slots `header`/`footer` — the\n game is laid out between them, it does nothing for it); \"Play\" is the game, any other tab opens\n that screen, the app starts in it. A game with its own bar to the systems hides it when\n `ctx.sharedUi.shouldDraw(\"menu\")` is false (idle-rpg does).\n- without it (the default) — the game takes the whole screen (voxelcraft, board-game, mini-game):\n \"Play\" in the lobby launches it, over it the base draws only a small \"To the lobby\" button.\n A game with its own way back (a pause menu with \"To the lobby\" → `ctx.lobby.open()`, and a pause\n button on touch screens) says `ownExit: true` in its route — then the base draws nothing over it\n (voxelcraft).\n\n- A system tab is hidden while the title has nothing for it (no store offers, no heroes…): the module\n registers with `available: false` and calls `ctx.features.setAvailable(id, ok, reason)`.\n- `ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type);\n the lobby builds \"Play\" from the `game` ones. Switch with `ctx.navigate(id)` (`\"lobby\"` is the base).\n- Open another system's screen by id: `ctx.features.open(\"store\")` — from a game too (the host shows\n it in a window while the lobby is off screen). Never import another module.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode and in the shop — same\n `client.currency`, same cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files. The client and the event bus are the seams\n between them — which is what keeps a module reusable in another game. Code several of THIS game's\n modules need (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a\n module (see idosgames-project-structure).\n\n## Shared chrome: roles, and the base takes them\n\nBalances, the crypto wallet, the status line, \"Log out\" / player ID, the menu of features and the\nswitch between modes are **shared-UI roles** (`currency-bar | wallet | status | account | menu |\nmodes`). A module that draws one for EVERY mode declares it statically on the module object —\nthe base does:\n\n```ts\ndefineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n /* the lobby: balances, wallet, profile, tabs, \"Play\" */\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider wins,\na conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`. A game draws its own\ncopy only while nobody took the role. So:\n\n- Next to the base a game hides its own balances and wallet by itself. Do NOT edit a catalog game to\n remove them: it does that itself, and an edit marks it customized (no more catalog updates).\n- `status` — the line of a game's own actions (\"Rolling…\") — stays with each game.\n- A game installed without a base stays complete — nobody owns a role, it draws everything, and the\n host draws its own nav of routes, ☰ and \"Log out\" (those are the host-drawn roles `modes`, `menu`,\n `account`).\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n\n**Layout is the host's job.** A game's overlay fills the screen under the base's small button; a\nscene's own DOM HUD can keep clear of host chrome with the CSS variables on the host root —\n`top: calc(var(--idos-safe-top, 0px) + 8px)`, `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)`\n(voxelcraft's hotbar does this). Both are 0 when nothing is pinned. `hud`-slot panels are wrapped in\n`display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let clicks\nthrough to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference app\n\nThe base + the systems + `board-game` + `idle-rpg`:\n\n- sign-in → the lobby: balances, wallet and profile on top, the systems as tabs, \"Play\" with two games;\n- \"Play\" → one engine scene mounted at a time; the lobby's tabs stay around an `inLobby` game, a\n full-screen game gets the button back;\n- the games hide their own balances and wallet (the lobby has them); their status lines stay theirs;\n- currency changed in one mode is immediately visible in the others and in the lobby; levelling a hero\n in Idle RPG shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
4
+ "content": "---\nname: idosgames-compose-modules\ndescription: >-\n Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards,\n quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this\n whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system,\n add a second game, switch between the lobby and game modes, share currency/inventory across modules,\n or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi,\n shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared\n state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single\n module).\n---\n\n# Putting an app together: the base + modules\n\nEvery project is **the base + modules**. The base is sign-in and the lobby and comes from the SDK\n(`@idosgames/app-shell`: `createBaseModule`, `DefaultLoginScreen`, `DefaultPlayAccessScreen`); the project\nconfigures it in `src/app.config.ts` (where the player lands, the tab order, guest sign-in), `src/ui.config.ts`\n(the look) and `src/main.tsx`. Everything else is a module in `src/modules/<id>/`:\n\n- **systems** (`type: feature`) — one per player-facing module of the engine: store, premium (VIP),\n character, inventory, lootboxes, boosts, leaderboard, rewards (daily), quests, collections, craft,\n marketplace, deals, events, coop-events, season, friends, chat, invite, pvp, multiplayer, profile,\n tutorials, workshop, ai, mailbox. Each registers its screen as a **feature** → a tab of the lobby.\n The base installs only six of them into every new project (`inBase: true`: profile, inventory,\n invite, friends, quests, mailbox); every other system is installed only when the app needs it — a game\n brings the ones it lists in `uses`, and you install any other one as soon as the task needs what\n it `provides` (InstallModule / `get_module`) — the user does not have to name the module; reuse it\n instead of writing that feature again. Never install one the task does not need.\n- **games** (`type: template`) — board-game, idle-rpg, voxelcraft, mini-game, or the game's own. Each\n registers a **route** → a \"Play\" button in the lobby. A game that plugs into a system lists it in\n `uses` in its module.meta.json (`ctx.multiplayer` → `\"multiplayer\"`, `ctx.content` → `\"workshop\"`;\n `npm run check` enforces those two) — install those together with the game.\n\n## Register modules\n\n```ts\n// src/modules.ts — the platform regenerates it; by hand it is imports + the array\nexport const modules: Module[] = [\n storeModule, // systems: lobby tabs\n characterModule,\n leaderboardModule,\n boardGameModule, // games: \"Play\"\n idleRpgModule,\n];\n```\n\n`src/main.tsx` mounts `[createBaseModule(appConfig, …), ...modules]`. After sign-in the player lands in the game that\nlives inside the lobby, or in the lobby when the games take the whole screen (`src/app.config.ts`\n`start`: `\"game\"` — the default, `\"lobby\"`, or a game's mode id). \"Play\" switches the host to the\ngame's mode: only the active mode's scene is mounted and ticking; the rest are suspended (their RAF\nstops). This is why two different engines (Three + Phaser) live in one project — they never render\nat the same time. Two kinds of game (owner's decision 03.10.2026 — chosen per game, never for all):\n\n- `ctx.registerRoute({ …, inLobby: true })` — the game sits INSIDE the lobby (idle-rpg): the base\n keeps the balances on top and the tabs at the bottom (host panel slots `header`/`footer` — the\n game is laid out between them, it does nothing for it); \"Play\" is the game, any other tab opens\n that screen, the app starts in it. A game with its own bar to the systems hides it when\n `ctx.sharedUi.shouldDraw(\"menu\")` is false (idle-rpg does).\n- without it (the default) — the game takes the whole screen (voxelcraft, board-game, mini-game):\n \"Play\" in the lobby launches it, over it the base draws only a small \"To the lobby\" button.\n A game with its own way back (a pause menu with \"To the lobby\" → `ctx.lobby.open()`, and a pause\n button on touch screens) says `ownExit: true` in its route — then the base draws nothing over it\n (voxelcraft).\n\n- A system tab is hidden while the title has nothing for it (no store offers, no heroes…): the module\n registers with `available: false` and calls `ctx.features.setAvailable(id, ok, reason)`.\n- `ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type);\n the lobby builds \"Play\" from the `game` ones. Switch with `ctx.navigate(id)` (`\"lobby\"` is the base).\n- Open another system's screen by id: `ctx.features.open(\"store\")` — from a game too (the host shows\n it in a window while the lobby is off screen). Never import another module.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode and in the shop — same\n `client.currency`, same cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files. The client and the event bus are the seams\n between them — which is what keeps a module reusable in another game. Code several of THIS game's\n modules need (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a\n module (see idosgames-project-structure).\n\n## Shared chrome: roles, and the base takes them\n\nBalances, the crypto wallet, the status line, \"Log out\" / player ID, the menu of features and the\nswitch between modes are **shared-UI roles** (`currency-bar | wallet | status | account | menu |\nmodes`). A module that draws one for EVERY mode declares it statically on the module object —\nthe base does:\n\n```ts\ndefineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n /* the lobby: balances, wallet, profile, tabs, \"Play\" */\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider wins,\na conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`. A game draws its own\ncopy only while nobody took the role. So:\n\n- Next to the base a game hides its own balances and wallet by itself. Do NOT edit a catalog game to\n remove them: it does that itself, and an edit marks it customized (no more catalog updates).\n- `status` — the line of a game's own actions (\"Rolling…\") — stays with each game.\n- A game installed without a base stays complete — nobody owns a role, it draws everything, and the\n host draws its own nav of routes, ☰ and \"Log out\" (those are the host-drawn roles `modes`, `menu`,\n `account`).\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n\n**Layout is the host's job.** A game's overlay fills the screen under the base's small button; a\nscene's own DOM HUD can keep clear of host chrome with the CSS variables on the host root —\n`top: calc(var(--idos-safe-top, 0px) + 8px)`, `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)`\n(voxelcraft's hotbar does this). Both are 0 when nothing is pinned. `hud`-slot panels are wrapped in\n`display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let clicks\nthrough to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference app\n\nThe base + the systems + `board-game` + `idle-rpg`:\n\n- sign-in → straight into the `inLobby` game (else the first game): balances, wallet and profile on\n top, the systems as tabs; \"Play\" lists the two games;\n- \"Play\" → one engine scene mounted at a time; the lobby's tabs stay around an `inLobby` game, a\n full-screen game gets the button back;\n- the games hide their own balances and wallet (the lobby has them); their status lines stay theirs;\n- currency changed in one mode is immediately visible in the others and in the lobby; levelling a hero\n in Idle RPG shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
5
5
  "references": []
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-getting-started",
3
3
  "description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and the games (board-game, idle-rpg, voxelcraft, mini-game, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as the project grows). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
4
- "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in\n + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and\n the games (board-game, idle-rpg, voxelcraft, mini-game, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …). Its\n **central cache** `client.cache` decides every read: the login brings the title config (by version)\n and the player's state (by each container's `Rev`) in ONE call; afterwards read state through\n `client.cache.ensureState([...containers])`, definitions through `getDefinitions()` (served from the\n config cache), server views through their service (cached by policy) — never re-fetch on start-up.\n The server views the game reads ride on that same login call (`ClientState.Views`): the client\n option `loginViews` — `\"auto\"` (default, what the game read last time), `\"all\"`, `\"none\"` or a list.\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Base** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/base`): sign-in and the lobby come from `@idosgames/app-shell` (mounted in `src/main.tsx`), `src/app.config.ts`\n (start in the lobby or a game, guest sign-in), an empty `src/modules.ts` → an empty lobby.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`. The base's systems are the modules with `inBase: true` (profile,\n inventory, invite, friends, quests, mailbox); add the game(s) with the modules each game `uses`, and any\n other system (shop, VIP, leaderboards…) only when the app needs it.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — sign-in → the lobby: the systems' screens are its tabs (a tab hides itself while the\n title has nothing for it) and \"Play\" launches a game on the whole screen with a small button back\n — or, for a game whose route says `inLobby: true` (idle-rpg), straight into that game inside the\n lobby, the lobby's balances and tabs around it. The lobby draws the balances and the wallet; games hide their own copies by\n themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`, `kanban:read`, `kanban:write`, `build:write`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n **Shipping the game itself** (permission `build:write`) also goes through this server — the\n platform hosts it on the Title's own address (`{titleid}.idos.games`) and its idosgames.com page:\n\n 1. `begin_build_upload { title_id }` → `build_id`, a one-hour `upload_url`, `asset_base`.\n 2. Build the game as static files with that base: `vite build --base=<asset_base>` (a relative\n `./` base also works — Unity WebGL builds already are). Use the LIVE title id, never `-DEV`.\n 3. Zip the CONTENTS of the output folder (`index.html` at the zip root; a single `dist/` top folder\n is accepted) and upload it: `curl -T build.zip \"<upload_url>\"` — a plain PUT, no headers.\n 4. `finish_build_upload { title_id, build_id, deploy }` — unpacks it into a new version (it shows up\n in the dashboard's version history) and returns `TestUrl` (DEV address). `deploy: true` puts it\n live for players right away; without it, check `TestUrl` and call it again with `deploy: true`.\n Still unpacking when it returns → poll `get_build`. `list_builds` shows recent versions.\n\n A game that is live but whose Title is not published yet has no idosgames.com page — the\n response says so; the publisher publishes it from the dashboard.\n\n ⚠ **The build must be the game itself.** On the Title's idosgames.com page the game may embed\n (`<iframe>`) only platform hosts — never another website, with no setting to allow one. A\n \"wrapper\" build that signs in and then frames the publisher's own site shows **\"Content\n unavailable\"** there instead of the game: build and upload the real game. Network calls\n (`fetch`/WebSocket) from the embedded game reach platform hosts plus the Title's\n `Blockchain.IframeConnectHosts` (`save_blockchain`; wallet-connection hosts are always refused).\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
4
+ "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in\n + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and\n the games (board-game, idle-rpg, voxelcraft, mini-game, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …). Its\n **central cache** `client.cache` decides every read: the login brings the title config (by version)\n and the player's state (by each container's `Rev`) in ONE call; afterwards read state through\n `client.cache.ensureState([...containers])`, definitions through `getDefinitions()` (served from the\n config cache), server views through their service (cached by policy) — never re-fetch on start-up.\n The server views the game reads ride on that same login call (`ClientState.Views`): the client\n option `loginViews` — `\"auto\"` (default, what the game read last time), `\"all\"`, `\"none\"` or a list.\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Base** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/base`): sign-in and the lobby come from `@idosgames/app-shell` (mounted in `src/main.tsx`), `src/app.config.ts`\n (start in the lobby or a game, guest sign-in), an empty `src/modules.ts` → an empty lobby.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`. The base's systems are the modules with `inBase: true` (profile,\n inventory, invite, friends, quests, mailbox); add the game(s) with the modules each game `uses`, and any\n other system (shop, VIP, leaderboards…) only when the app needs it.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — sign-in → straight INTO the game, never a \"Play\" click (owner's rule 07.11.2026): a game\n whose route says `inLobby: true` (idle-rpg) sits inside the lobby, the lobby's balances and tabs\n (the systems' screens; a tab hides itself while the title has nothing for it) around it; else the\n first game on the whole screen with a small button back to the lobby. The lobby draws the balances and the wallet; games hide their own copies by\n themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`, `kanban:read`, `kanban:write`, `build:write`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n **Shipping the game itself** (permission `build:write`) also goes through this server — the\n platform hosts it on the Title's own address (`{titleid}.idos.games`) and its idosgames.com page:\n\n 1. `begin_build_upload { title_id }` → `build_id`, a one-hour `upload_url`, `asset_base`.\n 2. Build the game as static files with that base: `vite build --base=<asset_base>` (a relative\n `./` base also works — Unity WebGL builds already are). Use the LIVE title id, never `-DEV`.\n 3. Zip the CONTENTS of the output folder (`index.html` at the zip root; a single `dist/` top folder\n is accepted) and upload it: `curl -T build.zip \"<upload_url>\"` — a plain PUT, no headers.\n 4. `finish_build_upload { title_id, build_id, deploy }` — unpacks it into a new version (it shows up\n in the dashboard's version history) and returns `TestUrl` (DEV address). `deploy: true` puts it\n live for players right away; without it, check `TestUrl` and call it again with `deploy: true`.\n Still unpacking when it returns → poll `get_build`. `list_builds` shows recent versions.\n\n A game that is live but whose Title is not published yet has no idosgames.com page — the\n response says so; the publisher publishes it from the dashboard.\n\n ⚠ **The build must be the game itself.** On the Title's idosgames.com page the game may embed\n (`<iframe>`) only platform hosts — never another website, with no setting to allow one. A\n \"wrapper\" build that signs in and then frames the publisher's own site shows **\"Content\n unavailable\"** there instead of the game: build and upload the real game. Network calls\n (`fetch`/WebSocket) from the embedded game reach platform hosts plus the Title's\n `Blockchain.IframeConnectHosts` (`save_blockchain`; wallet-connection hosts are always refused).\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
5
5
  "references": []
6
6
  }