@idosgames/mcp 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,15 +23,15 @@
23
23
  },
24
24
  {
25
25
  "path": "package.json",
26
- "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.2.0\",\n \"@idosgames/core\": \"0.12.0\",\n \"@idosgames/module-sdk\": \"0.2.0\",\n \"@idosgames/react\": \"0.2.5\",\n \"@idosgames/wallet\": \"0.2.5\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
26
+ "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.3.0\",\n \"@idosgames/core\": \"0.14.0\",\n \"@idosgames/module-sdk\": \"0.3.0\",\n \"@idosgames/react\": \"0.2.7\",\n \"@idosgames/wallet\": \"0.2.7\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
27
27
  },
28
28
  {
29
29
  "path": "public/sw.js",
30
- "content": "// Service worker for push notifications. This file is YOURS to edit — it ships as a starting\n// point, not as SDK code.\n//\n// ⚠⚠ THERE IS NO `fetch` HANDLER HERE, AND THERE MUST NEVER BE ONE.\n//\n// A caching service worker looks like a free win and is the single most expensive mistake you can\n// make on this platform. A cached `index.html` points at content-hashed chunks that the NEXT\n// deploy deletes; the player then gets a white screen served from INSIDE their own browser, where\n// neither a CDN purge nor Ctrl+F5 reaches it. The platform has already paid for that bug once, in\n// the publisher dashboard, and the fix was to stop caching HTML at all. A `fetch` handler here\n// would bring it back in a form that is much harder to undo. If you want offline support, do it\n// deliberately and never cache the document.\n//\n// ⚠ Registered RELATIVE to the page (`./sw.js`), so on a hosted build its scope is exactly\n// `/drive/app/{titleID}/`. That is what gives each game its own push subscription on the shared\n// `cloud.idosgames.com` origin. Do not add `Service-Worker-Allowed` to widen it — a wider scope\n// would let one game's worker answer for another's.\n\nself.addEventListener(\"install\", (event) => {\n // Take over immediately. Without this the new worker waits for every tab of this game to close,\n // and a player with the game open all day would keep the old one for days.\n event.waitUntil(self.skipWaiting());\n});\n\nself.addEventListener(\"activate\", (event) => {\n event.waitUntil(self.clients.claim());\n});\n\n/**\n * A notification arrived.\n *\n * ⚠ `showNotification` is NOT optional. The subscription was created with `userVisibleOnly: true`,\n * which is a promise to display every message; break it and the browser first shows its own\n * \"this site is sending background notifications\" warning, then revokes the permission.\n *\n * The payload is the small JSON the server encrypts per device: `{ title, body?, url?, icon? }`.\n * The text is already resolved into the device's language — do not translate it here.\n */\nself.addEventListener(\"push\", (event) => {\n let payload = {};\n try {\n payload = event.data ? event.data.json() : {};\n } catch {\n // Never let a malformed payload swallow the notification: the promise above still stands.\n }\n\n const title = payload.title || \"\";\n if (!title) return; // An empty notification is a blank grey box. Better to show nothing.\n\n event.waitUntil(\n self.registration.showNotification(title, {\n body: payload.body || undefined,\n icon: payload.icon || undefined,\n // Keeps a repeated notification from stacking into a pile the player has to dismiss one by\n // one. Give the server-side producer a stable key per kind of message.\n tag: payload.tag || undefined,\n data: { url: payload.url || \"./\" },\n }),\n );\n});\n\n/**\n * Where a click is allowed to take the player.\n *\n * The URL arrives IN THE PAYLOAD, and for game events the server takes it from the TITLE CONFIG.\n * Anything outside this worker's own scope is refused: a notification shown under your game's\n * name and icon must not be able to send the tab to an arbitrary site, which is exactly what a\n * phishing notification is for (\"your session expired, sign in again\").\n *\n * A refused URL does not cancel the click — the player pressed it, and opening the game is more\n * honest than doing nothing, which reads as a broken notification.\n */\nfunction safeTarget(raw) {\n const scope = self.registration.scope;\n\n try {\n const url = new URL(raw || scope, scope);\n return url.href.startsWith(scope) ? url.href : scope;\n } catch {\n return scope;\n }\n}\n\n/**\n * The player tapped the notification.\n *\n * Focuses an already-open tab of this game instead of opening a second one — two copies of the\n * same game in two tabs is its own kind of bug.\n */\nself.addEventListener(\"notificationclick\", (event) => {\n event.notification.close();\n\n const target = safeTarget(\n event.notification.data && event.notification.data.url,\n );\n\n event.waitUntil(\n self.clients\n .matchAll({ type: \"window\", includeUncontrolled: true })\n .then((clients) => {\n for (const client of clients) {\n if (client.url === target && \"focus\" in client) return client.focus();\n }\n return self.clients.openWindow\n ? self.clients.openWindow(target)\n : undefined;\n }),\n );\n});\n"
30
+ "content": "// Service worker for push notifications. This file is YOURS to edit — it ships as a starting\n// point, not as SDK code.\n//\n// ⚠⚠ THERE IS NO `fetch` HANDLER HERE, AND THERE MUST NEVER BE ONE.\n//\n// A caching service worker looks like a free win and is the single most expensive mistake you can\n// make on this platform. A cached `index.html` points at content-hashed chunks that the NEXT\n// deploy deletes; the player then gets a white screen served from INSIDE their own browser, where\n// neither a CDN purge nor Ctrl+F5 reaches it. The platform has already paid for that bug once, in\n// the publisher dashboard, and the fix was to stop caching HTML at all. A `fetch` handler here\n// would bring it back in a form that is much harder to undo. If you want offline support, do it\n// deliberately and never cache the document.\n//\n// ⚠ Registered RELATIVE to the page (`./sw.js`). A hosted build runs on the title's own origin,\n// `{titleid}.idos.games`, so the live game's worker covers that whole origin — and nothing else:\n// no other game lives there. A pinned version (`/v/{buildId}/`, the DEV preview) gets a worker\n// scoped to its own folder. On the legacy shared `cloud.idosgames.com` the relative path is what\n// kept each game in `/drive/app/{titleID}/`; do not add `Service-Worker-Allowed` to widen it.\n\nself.addEventListener(\"install\", (event) => {\n // Take over immediately. Without this the new worker waits for every tab of this game to close,\n // and a player with the game open all day would keep the old one for days.\n event.waitUntil(self.skipWaiting());\n});\n\nself.addEventListener(\"activate\", (event) => {\n event.waitUntil(self.clients.claim());\n});\n\n/**\n * A notification arrived.\n *\n * ⚠ `showNotification` is NOT optional. The subscription was created with `userVisibleOnly: true`,\n * which is a promise to display every message; break it and the browser first shows its own\n * \"this site is sending background notifications\" warning, then revokes the permission.\n *\n * The payload is the small JSON the server encrypts per device: `{ title, body?, url?, icon? }`.\n * The text is already resolved into the device's language — do not translate it here.\n */\nself.addEventListener(\"push\", (event) => {\n let payload = {};\n try {\n payload = event.data ? event.data.json() : {};\n } catch {\n // Never let a malformed payload swallow the notification: the promise above still stands.\n }\n\n const title = payload.title || \"\";\n if (!title) return; // An empty notification is a blank grey box. Better to show nothing.\n\n event.waitUntil(\n self.registration.showNotification(title, {\n body: payload.body || undefined,\n icon: payload.icon || undefined,\n // Keeps a repeated notification from stacking into a pile the player has to dismiss one by\n // one. Give the server-side producer a stable key per kind of message.\n tag: payload.tag || undefined,\n data: { url: payload.url || \"./\" },\n }),\n );\n});\n\n/**\n * Where a click is allowed to take the player.\n *\n * The URL arrives IN THE PAYLOAD, and for game events the server takes it from the TITLE CONFIG.\n * Anything outside this worker's own scope is refused: a notification shown under your game's\n * name and icon must not be able to send the tab to an arbitrary site, which is exactly what a\n * phishing notification is for (\"your session expired, sign in again\").\n *\n * A refused URL does not cancel the click — the player pressed it, and opening the game is more\n * honest than doing nothing, which reads as a broken notification.\n */\nfunction safeTarget(raw) {\n const scope = self.registration.scope;\n\n try {\n const url = new URL(raw || scope, scope);\n return url.href.startsWith(scope) ? url.href : scope;\n } catch {\n return scope;\n }\n}\n\n/**\n * The player tapped the notification.\n *\n * Focuses an already-open tab of this game instead of opening a second one — two copies of the\n * same game in two tabs is its own kind of bug.\n */\nself.addEventListener(\"notificationclick\", (event) => {\n event.notification.close();\n\n const target = safeTarget(\n event.notification.data && event.notification.data.url,\n );\n\n event.waitUntil(\n self.clients\n .matchAll({ type: \"window\", includeUncontrolled: true })\n .then((clients) => {\n for (const client of clients) {\n if (client.url === target && \"focus\" in client) return client.focus();\n }\n return self.clients.openWindow\n ? self.clients.openWindow(target)\n : undefined;\n }),\n );\n});\n"
31
31
  },
32
32
  {
33
33
  "path": "src/config.ts",
34
- "content": "// Which Title this app talks to. Always targets the real backend (https://api.idosgames.com).\n//\n// IDENTITY and ENVIRONMENT are resolved separately, and that separation is the point:\n//\n// identity — WHICH game. Resolved once, in this order:\n// 1. src/idos.title.ts — the centralized identity file (generated by the platform, or filled\n// by hand on a manual scaffold). Wins over everything.\n// 2. /drive/app/{id}/… — the hosted artifact's own path.\n// 3. ?titleID= — explicit override, for debugging and staged previews.\n// 4. VITE_IDOS_TITLE_ID — local development, via .env.local.\n// There is deliberately NO fallback title. A build that cannot tell which game it is must fail\n// loudly rather than quietly sign in to somebody else's data.\n//\n// environment — WHICH copy of that game, prod or dev. A modifier on the identity, never a\n// different identity: the dev title is always `{id}-DEV`. That is what lets a staged preview run\n// the production artifact against dev data without the baked identity getting in the way.\n//\n// Baking identity into a file instead of reading the URL is what lets the same source ship as a\n// packaged mobile app, where there is no URL to read at all.\n\nimport { IDOS_BUILD_KEY, IDOS_DEFAULT_ENV, IDOS_TITLE_ID } from \"./idos.title\";\nimport { ENV_BUILD_KEY, ENV_TITLE_ID } from \"./env\";\n\n/** Mirrors DevTitleService.DevSuffix on the backend. */\nconst DEV_SUFFIX = \"-DEV\";\n\nconst launchParams =\n typeof window === \"undefined\"\n ? null\n : new URLSearchParams(window.location.search);\n\n/**\n * Hosted AI Coder artifacts live at `/drive/app/{titleId}/…` (live pointer) and\n * `/drive/app/{titleId}/v/{buildId}/…` (a pinned version), so the path already identifies the\n * title. Deliberately narrow: the shared per-template builds under `/bld/{templateId}/` serve many\n * titles from one artifact, and guessing a title from that path would be wrong.\n */\nfunction titleFromPath(): string | null {\n if (typeof window === \"undefined\") return null;\n const match = window.location.pathname.match(\n /\\/drive\\/app\\/([A-Za-z0-9_-]{1,64})\\//,\n );\n return match?.[1] ?? null;\n}\n\n/** Identity is always canonical; the dev suffix is an environment, so strip it if an override carries one. */\nfunction toCanonical(id: string): string {\n return id.endsWith(DEV_SUFFIX) ? id.slice(0, -DEV_SUFFIX.length) : id;\n}\n\nconst overrideTitle = launchParams?.get(\"titleID\") || null;\nconst envParam = launchParams?.get(\"env\") || null;\n\nconst canonicalTitle =\n IDOS_TITLE_ID ||\n titleFromPath() ||\n (overrideTitle ? toCanonical(overrideTitle) : null) ||\n ENV_TITLE_ID;\n\nif (!canonicalTitle) {\n throw new Error(\n \"[idos] No Title id resolved. Expected src/idos.title.ts (generated by the platform), \" +\n \"a /drive/app/{titleID}/ artifact path, ?titleID= on the URL, or VITE_IDOS_TITLE_ID in .env.local.\",\n );\n}\n\n/**\n * Precedence: explicit `?env` → the suffix on an explicit `?titleID` (how staged previews run the\n * prod artifact against dev data) → the baked default, which is the only channel a packaged mobile\n * build has.\n */\nfunction resolveIsDev(): boolean {\n if (envParam !== null) return envParam === \"dev\";\n if (overrideTitle !== null) return overrideTitle.endsWith(DEV_SUFFIX);\n return IDOS_DEFAULT_ENV === \"dev\";\n}\n\nconst isDev = resolveIsDev();\n\nexport const TITLE_ID = isDev\n ? `${canonicalTitle}${DEV_SUFFIX}`\n : canonicalTitle;\n\nexport const BUILD_KEY =\n IDOS_BUILD_KEY || launchParams?.get(\"buildKey\") || ENV_BUILD_KEY;\n"
34
+ "content": "// Which Title this app talks to. Always targets the real backend (https://api.idosgames.com).\n//\n// IDENTITY and ENVIRONMENT are resolved separately, and that separation is the point:\n//\n// identity — WHICH game. Resolved once, in this order:\n// 1. src/idos.title.ts — the centralized identity file (generated by the platform, or filled\n// by hand on a manual scaffold). Wins over everything.\n// 2. {id}.idos.games — the title's own origin, where hosted builds are served.\n// 3. /drive/app/{id}/… — the hosted artifact's path (legacy cloud.idosgames.com links).\n// 4. ?titleID= — explicit override, for debugging and staged previews.\n// 5. VITE_IDOS_TITLE_ID — local development, via .env.local.\n// There is deliberately NO fallback title. A build that cannot tell which game it is must fail\n// loudly rather than quietly sign in to somebody else's data.\n//\n// environment — WHICH copy of that game, prod or dev. A modifier on the identity, never a\n// different identity: the dev title is always `{id}-DEV`. That is what lets a staged preview run\n// the production artifact against dev data without the baked identity getting in the way.\n//\n// Baking identity into a file instead of reading the URL is what lets the same source ship as a\n// packaged mobile app, where there is no URL to read at all.\n\nimport { IDOS_BUILD_KEY, IDOS_DEFAULT_ENV, IDOS_TITLE_ID } from \"./idos.title\";\nimport { ENV_BUILD_KEY, ENV_TITLE_ID } from \"./env\";\n\n/** Mirrors DevTitleService.DevSuffix on the backend. */\nconst DEV_SUFFIX = \"-DEV\";\n\nconst launchParams =\n typeof window === \"undefined\"\n ? null\n : new URLSearchParams(window.location.search);\n\n/**\n * Hosted AI Coder artifacts live at `/drive/app/{titleId}/…` (live pointer) and\n * `/drive/app/{titleId}/v/{buildId}/…` (a pinned version), so the path already identifies the\n * title. Deliberately narrow: the shared per-template builds under `/bld/{templateId}/` serve many\n * titles from one artifact, and guessing a title from that path would be wrong.\n */\nfunction titleFromPath(): string | null {\n if (typeof window === \"undefined\") return null;\n const match = window.location.pathname.match(\n /\\/drive\\/app\\/([A-Za-z0-9_-]{1,64})\\//,\n );\n return match?.[1] ?? null;\n}\n\n/**\n * Hosted builds run on the title's own origin, `{titleid}.idos.games`: title ids are 8 characters\n * of [A-Z0-9] and hostnames are case-insensitive, so the lowercase id IS the address. Mirrors the\n * platform's game-host Worker (TitleHostWorker/src/host.ts). A publisher's own domain carries no\n * id — that is what idos.title.ts is for.\n */\nfunction titleFromHost(): string | null {\n if (typeof window === \"undefined\") return null;\n const match = window.location.hostname\n .toLowerCase()\n .match(/^([a-z0-9]{8})\\.idos\\.games$/);\n return match?.[1]?.toUpperCase() ?? null;\n}\n\n/** Identity is always canonical; the dev suffix is an environment, so strip it if an override carries one. */\nfunction toCanonical(id: string): string {\n return id.endsWith(DEV_SUFFIX) ? id.slice(0, -DEV_SUFFIX.length) : id;\n}\n\nconst overrideTitle = launchParams?.get(\"titleID\") || null;\nconst envParam = launchParams?.get(\"env\") || null;\n\nconst canonicalTitle =\n IDOS_TITLE_ID ||\n titleFromHost() ||\n titleFromPath() ||\n (overrideTitle ? toCanonical(overrideTitle) : null) ||\n ENV_TITLE_ID;\n\nif (!canonicalTitle) {\n throw new Error(\n \"[idos] No Title id resolved. Expected src/idos.title.ts (generated by the platform), \" +\n \"a {titleid}.idos.games host, a /drive/app/{titleID}/ artifact path, ?titleID= on the URL, \" +\n \"or VITE_IDOS_TITLE_ID in .env.local.\",\n );\n}\n\n/**\n * Precedence: explicit `?env` → the suffix on an explicit `?titleID` (how staged previews run the\n * prod artifact against dev data) → the baked default, which is the only channel a packaged mobile\n * build has.\n */\nfunction resolveIsDev(): boolean {\n if (envParam !== null) return envParam === \"dev\";\n if (overrideTitle !== null) return overrideTitle.endsWith(DEV_SUFFIX);\n return IDOS_DEFAULT_ENV === \"dev\";\n}\n\nconst isDev = resolveIsDev();\n\nexport const TITLE_ID = isDev\n ? `${canonicalTitle}${DEV_SUFFIX}`\n : canonicalTitle;\n\nexport const BUILD_KEY =\n IDOS_BUILD_KEY || launchParams?.get(\"buildKey\") || ENV_BUILD_KEY;\n"
35
35
  },
36
36
  {
37
37
  "path": "src/env.ts",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  {
45
45
  "path": "src/LoginScreen.tsx",
46
- "content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { beginSsoRedirect } from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"./env\";\nimport { LOGO_DATA_URL } from \"./logo\";\n\n// The Login scene. The host runtime owns WHEN this is shown (the auth gate in\n// @idosgames/app-shell); this file owns what it LOOKS like and which providers it offers.\n// Edit freely — branding, layout, copy, buttons.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus resetPassword.\n//\n// A provider is only rendered when it can actually complete, so players never meet a dead button:\n// guest / email — always available, no external setup.\n// Google — needs VITE_IDOS_GOOGLE_CLIENT_ID and the Google Identity script on the page.\n// wallet — always offered; ./walletLogin owns the wagmi config and the challenge network.\n// iDos Games — only where the platform accepts a return_to (see isSsoAvailable below).\n\n// Куда платформа соглашается вернуть одноразовый код. Список повторяет allowlist на бэкенде\n// (SsoService.AllowedOrigins) НАМЕРЕННО: здесь он решает только, показывать ли кнопку, а\n// настоящий барьер стоит на сервере. Показать кнопку там, где сервер откажет, — значит\n// пообещать игроку вход, который не состоится.\nconst SSO_ORIGINS = [\n \"https://cloud.idosgames.com\",\n \"https://idosgames.com\",\n \"https://www.idosgames.com\",\n];\n\nfunction isSsoAvailable(): boolean {\n return (\n typeof window !== \"undefined\" &&\n SSO_ORIGINS.includes(window.location.origin)\n );\n}\n\nexport interface LoginScreenExtras {\n /**\n * Wallet sign-in. Supplied by ./walletLogin (wired in main.tsx), which renders the ready-made\n * `WalletLogin` from `@idosgames/wallet/react`: connect → sign the challenge → session, then\n * `onAuthenticated()`. The screen passes its own `style` so the button matches the theme.\n *\n * Note: a wallet session is never restored silently (a fresh signature is required on every\n * launch), so keep at least one other provider for players who want to come straight back in.\n */\n renderWalletLogin?: (props: {\n client: LoginScreenProps[\"client\"];\n onAuthenticated: () => void;\n disabled: boolean;\n style?: CSSProperties;\n }) => ReactNode;\n}\n\n/** Пауза повторной отправки, когда сервер её не назвал. Совпадает со значением платформы. */\nconst DEFAULT_RESEND_COOLDOWN = 60;\n\n/**\n * Служебный код отказа → фраза, которую можно показать игроку.\n *\n * ⚠ Без этого экран входа показывал коды КАК ЕСТЬ: игрок видел «EMAIL_SENDER_NOT_CONFIGURED» или\n * «VERIFICATION_CODE_ATTEMPTS_EXCEEDED» вместо объяснения. Это шаблон, с которого начинается\n * каждая игра издателя, поэтому такое уезжает сразу всем.\n *\n * Незнакомый код возвращается как есть — намеренно: издателю на стенде он полезнее, чем общая\n * фраза «что-то пошло не так», а список ниже растёт по мере появления новых.\n */\nfunction humanizeAuthError(code: string | undefined): string {\n switch (code) {\n case \"EMAIL_SENDER_NOT_CONFIGURED\":\n return \"Sign-in by e-mail is unavailable in this game right now. Try another way to sign in.\";\n case \"INVALID_VERIFICATION_CODE\":\n return \"That code is not right. Check the e-mail and try again.\";\n case \"VERIFICATION_CODE_ATTEMPTS_EXCEEDED\":\n return \"Too many wrong attempts. Ask for a new code.\";\n case \"INCORRECT_EMAIL_OR_PASSWORD\":\n return \"Wrong e-mail or password.\";\n case \"INCORRECT_EMAIL\":\n return \"That does not look like an e-mail address.\";\n case \"PASSWORD_LENGTH_INVALID\":\n return \"The password must be 8 to 100 characters long.\";\n case \"TOO_MANY_FAILED_ATTEMPTS\":\n return \"Too many attempts. Please try again a little later.\";\n case \"RATE_LIMIT_EXCEEDED\":\n return \"Too many requests. Please try again in a moment.\";\n default:\n return code ?? \"Something went wrong. Please try again.\";\n }\n}\n\ntype Mode = \"menu\" | \"email\" | \"verify\";\n\n/** Minimal Google Identity surface — declared here so the template needs no @types/google.accounts. */\ntype GoogleIdentity = {\n accounts: {\n id: {\n initialize(config: {\n client_id: string;\n callback: (response: { credential?: string }) => void;\n }): void;\n prompt(): void;\n };\n };\n};\n\nexport function LoginScreen({\n client,\n onAuthenticated,\n renderWalletLogin,\n}: LoginScreenProps & LoginScreenExtras): ReactNode {\n const [mode, setMode] = useState<Mode>(\"menu\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [registering, setRegistering] = useState(false);\n const [code, setCode] = useState(\"\");\n const [resendIn, setResendIn] = useState(0);\n const [resendCooldown, setResendCooldown] = useState(DEFAULT_RESEND_COOLDOWN);\n\n const [remember, setRemember] = useState(true);\n\n /** Every provider goes through here, so one place owns the busy flag and the error surface. */\n const run = async (\n login: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n setError(null);\n // \"Remember me\" is read when the login completes, so set it before starting one. Off = this\n // session works normally but is not written to storage, so the next launch lands here again.\n client.auth.setRememberSession(remember);\n const result = await login();\n if (result.ok) {\n onAuthenticated();\n return;\n }\n setError(\n humanizeAuthError(result.error) ?? \"Sign-in failed. Please try again.\",\n );\n setBusy(false);\n };\n\n /**\n * Регистрация — единственный провайдер, который НЕ обязательно заканчивается входом.\n *\n * Когда тайтл требует подтверждения адреса (а это значение платформы), сервер только отправляет\n * код, и аккаунта ещё нет. Поэтому она идёт мимо `run`: тот на успехе сразу зовёт\n * `onAuthenticated()`, а здесь на успехе надо показать экран ввода кода.\n */\n const register = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n client.auth.setRememberSession(remember);\n\n const result = await client.auth.registerWithEmail(email, password);\n\n if (!result.ok) {\n setError(\n humanizeAuthError(result.error) ?? \"Sign-up failed. Please try again.\",\n );\n setBusy(false);\n return;\n }\n\n // Аккаунта ещё нет — он появится на подтверждении. Уйти в игру здесь значило бы показать\n // пустую сессию.\n setCode(\"\");\n\n // Паузу задаёт СЕРВЕР (её настраивает издатель), поэтому запоминаем её и дальше берём\n // отсюда. Раньше первый отсчёт шёл от ответа, а каждый следующий — от захардкоженных 60\n // секунд: у тайтла с другой настройкой кнопка либо открывалась раньше, чем сервер согласен\n // слать (нажатие впустую, ответ всё равно успешный), либо держалась закрытой дольше нужного.\n const cooldown = result.data.resendCooldownSeconds ?? 0;\n setResendCooldown(cooldown > 0 ? cooldown : DEFAULT_RESEND_COOLDOWN);\n\n setResendIn(cooldown);\n setMode(\"verify\");\n setBusy(false);\n };\n\n const resendCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n\n const result = await client.auth.resendVerificationCode(email);\n\n // Ответ почти всегда успешный — начата регистрация или нет, выдержана пауза или нет. Иначе\n // эта кнопка отвечала бы на вопрос «заведён ли такой адрес». Поэтому и таймер заводим всегда.\n //\n // ⚠ Но ОДИН отказ отсюда приходит и его нельзя глотать: «слать нечем» (у тайтла и у\n // платформы нет отправителя). Он про конфигурацию сервера, а не про адрес, поэтому и\n // безопасен, и обязателен — иначе игрок жмёт кнопку до посинения, ожидая письма, которого\n // никто не отправлял.\n if (!result.ok) setError(humanizeAuthError(result.error));\n\n setResendIn(resendCooldown);\n setBusy(false);\n };\n\n // Обратный отсчёт до следующей отправки. Без него игрок жмёт «ещё раз» вслепую, а сервер молча\n // отказывает — и выглядит это как сломанная кнопка.\n useEffect(() => {\n if (resendIn <= 0) return;\n const id = setTimeout(() => setResendIn((v) => v - 1), 1000);\n return () => clearTimeout(id);\n }, [resendIn]);\n\n const signInWithGoogle = (): void => {\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: ENV_GOOGLE_CLIENT_ID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: rgba(255, 255, 255, 0.65); }`}</style>\n <div style={styles.card}>\n <img src={LOGO_DATA_URL} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: { ...styles.button, ...styles.primary },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {isSsoAvailable() && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {ENV_GOOGLE_CLIENT_ID && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={signInWithGoogle}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </button>\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n registering\n ? void register()\n : void run(() => client.auth.loginWithEmail(email, password))\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"verify\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n We sent a code to <strong>{email}</strong>. Enter it to finish\n creating your account.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Confirmation code\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n client.auth.confirmEmailRegistration(email, code),\n )\n }\n disabled={busy || !code}\n >\n {busy ? \"Please wait…\" : \"Confirm\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void resendCode()}\n disabled={busy || resendIn > 0}\n >\n {resendIn > 0\n ? `Send again in ${resendIn}s`\n : \"Send the code again\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {/* Applies to every provider above. A wallet sign-in ignores it — those are never\n restored silently, a fresh signature is required on each launch. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? \"#fff\" : \"rgba(255, 255, 255, 0.3)\",\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n background: remember ? \"#0d66fe\" : \"#fff\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// Brand look: iDos Games blue with the white logo; controls are translucent white on top of it,\n// the primary action is solid white with blue text.\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"#0d66fe\",\n color: \"#fff\",\n font: \"14px system-ui, sans-serif\",\n },\n card: { width: \"min(340px, 88vw)\", display: \"grid\", gap: \"18px\" },\n logo: {\n width: \"180px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: \"rgba(255, 255, 255, 0.85)\",\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n transition: \"left 0.15s, background 0.15s\",\n },\n title: { margin: 0, fontSize: \"22px\", fontWeight: 600, textAlign: \"center\" },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.4)\",\n background: \"rgba(255, 255, 255, 0.14)\",\n color: \"inherit\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n primary: {\n background: \"#fff\",\n borderColor: \"#fff\",\n color: \"#0d66fe\",\n fontWeight: 600,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: \"rgba(255, 255, 255, 0.85)\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n input: {\n padding: \"11px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.35)\",\n background: \"rgba(255, 255, 255, 0.12)\",\n color: \"inherit\",\n font: \"inherit\",\n },\n error: { margin: 0, color: \"#ffd7d7\", textAlign: \"center\" },\n hint: {\n margin: 0,\n color: \"rgba(255, 255, 255, 0.85)\",\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
46
+ "content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { beginSsoRedirect } from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"./env\";\nimport { LOGO_DATA_URL } from \"./logo\";\n\n// The Login scene. The host runtime owns WHEN this is shown (the auth gate in\n// @idosgames/app-shell); this file owns what it LOOKS like and which providers it offers.\n// Edit freely — branding, layout, copy, buttons.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus resetPassword.\n//\n// A provider is only rendered when it can actually complete, so players never meet a dead button:\n// guest / email — always available, no external setup.\n// Google — needs VITE_IDOS_GOOGLE_CLIENT_ID and the Google Identity script on the page.\n// wallet — always offered; ./walletLogin owns the wagmi config and the challenge network.\n// iDos Games — only where the platform accepts a return_to (see isSsoAvailable below).\n\n// Куда платформа соглашается вернуть одноразовый код. Список повторяет allowlist на бэкенде\n// (SsoService.AllowedOrigins) НАМЕРЕННО: здесь он решает только, показывать ли кнопку, а\n// настоящий барьер стоит на сервере. Показать кнопку там, где сервер откажет, — значит\n// пообещать игроку вход, который не состоится.\nconst SSO_ORIGINS = [\n \"https://cloud.idosgames.com\",\n \"https://idosgames.com\",\n \"https://www.idosgames.com\",\n];\n\n// Свой адрес игры: {titleid}.idos.games. Сервер примет его ТОЛЬКО для этого же тайтла.\nconst GAME_HOST = /^https:\\/\\/[a-z0-9]{8}\\.idos\\.games$/;\n\nfunction isSsoAvailable(): boolean {\n if (typeof window === \"undefined\") return false;\n const { origin } = window.location;\n if (SSO_ORIGINS.includes(origin) || GAME_HOST.test(origin)) return true;\n\n // Свой домен издателя (play.brand.com) id тайтла не несёт, и по origin не понять, сработает ли\n // вход. Страницу, которую отдаёт платформа, её сервер помечает этой меткой — а на свой домен он\n // отдаёт игру, только когда домен подключён к ЭТОМУ тайтлу. В превью и на чужом хостинге метки нет.\n return (\n typeof document !== \"undefined\" &&\n document.querySelector('meta[name=\"idos-game-host\"]') !== null\n );\n}\n\nexport interface LoginScreenExtras {\n /**\n * Wallet sign-in. Supplied by ./walletLogin (wired in main.tsx), which renders the ready-made\n * `WalletLogin` from `@idosgames/wallet/react`: connect → sign the challenge → session, then\n * `onAuthenticated()`. The screen passes its own `style` so the button matches the theme.\n *\n * Note: a wallet session is never restored silently (a fresh signature is required on every\n * launch), so keep at least one other provider for players who want to come straight back in.\n */\n renderWalletLogin?: (props: {\n client: LoginScreenProps[\"client\"];\n onAuthenticated: () => void;\n disabled: boolean;\n style?: CSSProperties;\n }) => ReactNode;\n}\n\n/** Пауза повторной отправки, когда сервер её не назвал. Совпадает со значением платформы. */\nconst DEFAULT_RESEND_COOLDOWN = 60;\n\n/**\n * Служебный код отказа → фраза, которую можно показать игроку.\n *\n * ⚠ Без этого экран входа показывал коды КАК ЕСТЬ: игрок видел «EMAIL_SENDER_NOT_CONFIGURED» или\n * «VERIFICATION_CODE_ATTEMPTS_EXCEEDED» вместо объяснения. Это шаблон, с которого начинается\n * каждая игра издателя, поэтому такое уезжает сразу всем.\n *\n * Незнакомый код возвращается как есть — намеренно: издателю на стенде он полезнее, чем общая\n * фраза «что-то пошло не так», а список ниже растёт по мере появления новых.\n */\nfunction humanizeAuthError(code: string | undefined): string {\n switch (code) {\n case \"EMAIL_SENDER_NOT_CONFIGURED\":\n return \"Sign-in by e-mail is unavailable in this game right now. Try another way to sign in.\";\n case \"INVALID_VERIFICATION_CODE\":\n return \"That code is not right. Check the e-mail and try again.\";\n case \"VERIFICATION_CODE_ATTEMPTS_EXCEEDED\":\n return \"Too many wrong attempts. Ask for a new code.\";\n case \"INCORRECT_EMAIL_OR_PASSWORD\":\n return \"Wrong e-mail or password.\";\n case \"INCORRECT_EMAIL\":\n return \"That does not look like an e-mail address.\";\n case \"PASSWORD_LENGTH_INVALID\":\n return \"The password must be 8 to 100 characters long.\";\n case \"TOO_MANY_FAILED_ATTEMPTS\":\n return \"Too many attempts. Please try again a little later.\";\n case \"RATE_LIMIT_EXCEEDED\":\n return \"Too many requests. Please try again in a moment.\";\n default:\n return code ?? \"Something went wrong. Please try again.\";\n }\n}\n\ntype Mode = \"menu\" | \"email\" | \"verify\";\n\n/** Minimal Google Identity surface — declared here so the template needs no @types/google.accounts. */\ntype GoogleIdentity = {\n accounts: {\n id: {\n initialize(config: {\n client_id: string;\n callback: (response: { credential?: string }) => void;\n }): void;\n prompt(): void;\n };\n };\n};\n\nexport function LoginScreen({\n client,\n onAuthenticated,\n renderWalletLogin,\n}: LoginScreenProps & LoginScreenExtras): ReactNode {\n const [mode, setMode] = useState<Mode>(\"menu\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [registering, setRegistering] = useState(false);\n const [code, setCode] = useState(\"\");\n const [resendIn, setResendIn] = useState(0);\n const [resendCooldown, setResendCooldown] = useState(DEFAULT_RESEND_COOLDOWN);\n\n const [remember, setRemember] = useState(true);\n\n /** Every provider goes through here, so one place owns the busy flag and the error surface. */\n const run = async (\n login: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n setError(null);\n // \"Remember me\" is read when the login completes, so set it before starting one. Off = this\n // session works normally but is not written to storage, so the next launch lands here again.\n client.auth.setRememberSession(remember);\n const result = await login();\n if (result.ok) {\n onAuthenticated();\n return;\n }\n setError(\n humanizeAuthError(result.error) ?? \"Sign-in failed. Please try again.\",\n );\n setBusy(false);\n };\n\n /**\n * Регистрация — единственный провайдер, который НЕ обязательно заканчивается входом.\n *\n * Когда тайтл требует подтверждения адреса (а это значение платформы), сервер только отправляет\n * код, и аккаунта ещё нет. Поэтому она идёт мимо `run`: тот на успехе сразу зовёт\n * `onAuthenticated()`, а здесь на успехе надо показать экран ввода кода.\n */\n const register = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n client.auth.setRememberSession(remember);\n\n const result = await client.auth.registerWithEmail(email, password);\n\n if (!result.ok) {\n setError(\n humanizeAuthError(result.error) ?? \"Sign-up failed. Please try again.\",\n );\n setBusy(false);\n return;\n }\n\n // Аккаунта ещё нет — он появится на подтверждении. Уйти в игру здесь значило бы показать\n // пустую сессию.\n setCode(\"\");\n\n // Паузу задаёт СЕРВЕР (её настраивает издатель), поэтому запоминаем её и дальше берём\n // отсюда. Раньше первый отсчёт шёл от ответа, а каждый следующий — от захардкоженных 60\n // секунд: у тайтла с другой настройкой кнопка либо открывалась раньше, чем сервер согласен\n // слать (нажатие впустую, ответ всё равно успешный), либо держалась закрытой дольше нужного.\n const cooldown = result.data.resendCooldownSeconds ?? 0;\n setResendCooldown(cooldown > 0 ? cooldown : DEFAULT_RESEND_COOLDOWN);\n\n setResendIn(cooldown);\n setMode(\"verify\");\n setBusy(false);\n };\n\n const resendCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n\n const result = await client.auth.resendVerificationCode(email);\n\n // Ответ почти всегда успешный — начата регистрация или нет, выдержана пауза или нет. Иначе\n // эта кнопка отвечала бы на вопрос «заведён ли такой адрес». Поэтому и таймер заводим всегда.\n //\n // ⚠ Но ОДИН отказ отсюда приходит и его нельзя глотать: «слать нечем» (у тайтла и у\n // платформы нет отправителя). Он про конфигурацию сервера, а не про адрес, поэтому и\n // безопасен, и обязателен — иначе игрок жмёт кнопку до посинения, ожидая письма, которого\n // никто не отправлял.\n if (!result.ok) setError(humanizeAuthError(result.error));\n\n setResendIn(resendCooldown);\n setBusy(false);\n };\n\n // Обратный отсчёт до следующей отправки. Без него игрок жмёт «ещё раз» вслепую, а сервер молча\n // отказывает — и выглядит это как сломанная кнопка.\n useEffect(() => {\n if (resendIn <= 0) return;\n const id = setTimeout(() => setResendIn((v) => v - 1), 1000);\n return () => clearTimeout(id);\n }, [resendIn]);\n\n const signInWithGoogle = (): void => {\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: ENV_GOOGLE_CLIENT_ID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: rgba(255, 255, 255, 0.65); }`}</style>\n <div style={styles.card}>\n <img src={LOGO_DATA_URL} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: { ...styles.button, ...styles.primary },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {isSsoAvailable() && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {ENV_GOOGLE_CLIENT_ID && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={signInWithGoogle}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </button>\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n registering\n ? void register()\n : void run(() => client.auth.loginWithEmail(email, password))\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"verify\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n We sent a code to <strong>{email}</strong>. Enter it to finish\n creating your account.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Confirmation code\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n client.auth.confirmEmailRegistration(email, code),\n )\n }\n disabled={busy || !code}\n >\n {busy ? \"Please wait…\" : \"Confirm\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void resendCode()}\n disabled={busy || resendIn > 0}\n >\n {resendIn > 0\n ? `Send again in ${resendIn}s`\n : \"Send the code again\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {/* Applies to every provider above. A wallet sign-in ignores it — those are never\n restored silently, a fresh signature is required on each launch. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? \"#fff\" : \"rgba(255, 255, 255, 0.3)\",\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n background: remember ? \"#0d66fe\" : \"#fff\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// Brand look: iDos Games blue with the white logo; controls are translucent white on top of it,\n// the primary action is solid white with blue text.\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"#0d66fe\",\n color: \"#fff\",\n font: \"14px system-ui, sans-serif\",\n },\n card: { width: \"min(340px, 88vw)\", display: \"grid\", gap: \"18px\" },\n logo: {\n width: \"180px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: \"rgba(255, 255, 255, 0.85)\",\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n transition: \"left 0.15s, background 0.15s\",\n },\n title: { margin: 0, fontSize: \"22px\", fontWeight: 600, textAlign: \"center\" },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.4)\",\n background: \"rgba(255, 255, 255, 0.14)\",\n color: \"inherit\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n primary: {\n background: \"#fff\",\n borderColor: \"#fff\",\n color: \"#0d66fe\",\n fontWeight: 600,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: \"rgba(255, 255, 255, 0.85)\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n input: {\n padding: \"11px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.35)\",\n background: \"rgba(255, 255, 255, 0.12)\",\n color: \"inherit\",\n font: \"inherit\",\n },\n error: { margin: 0, color: \"#ffd7d7\", textAlign: \"center\" },\n hint: {\n margin: 0,\n color: \"rgba(255, 255, 255, 0.85)\",\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
47
47
  },
48
48
  {
49
49
  "path": "src/logo.ts",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedFromCommit": "0b7cc31dc8e4add49f8b7077653e4399ed9ca00c",
2
+ "generatedFromCommit": "10ad87a09e9b0fb2e84075caea2cd4f183b7b560",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.12.0",
5
- "@idosgames/wallet": "0.2.5",
6
- "@idosgames/module-sdk": "0.2.0",
7
- "@idosgames/react": "0.2.5",
8
- "@idosgames/app-shell": "0.2.0"
4
+ "@idosgames/core": "0.14.0",
5
+ "@idosgames/wallet": "0.2.7",
6
+ "@idosgames/module-sdk": "0.3.0",
7
+ "@idosgames/react": "0.2.7",
8
+ "@idosgames/app-shell": "0.3.0"
9
9
  },
10
10
  "host": {
11
11
  "id": "host-starter",
@@ -54,10 +54,10 @@
54
54
  ]
55
55
  },
56
56
  "dependencies": {
57
- "@idosgames/core": "0.12.0",
58
- "@idosgames/module-sdk": "0.2.0",
59
- "@idosgames/react": "0.2.5",
60
- "@idosgames/wallet": "0.2.5",
57
+ "@idosgames/core": "0.14.0",
58
+ "@idosgames/module-sdk": "0.3.0",
59
+ "@idosgames/react": "0.2.7",
60
+ "@idosgames/wallet": "0.2.7",
61
61
  "@tanstack/react-query": "5.101.2",
62
62
  "react": "19.2.7",
63
63
  "three": "0.185.1",
@@ -113,10 +113,10 @@
113
113
  ]
114
114
  },
115
115
  "dependencies": {
116
- "@idosgames/core": "0.12.0",
117
- "@idosgames/module-sdk": "0.2.0",
118
- "@idosgames/react": "0.2.5",
119
- "@idosgames/wallet": "0.2.5",
116
+ "@idosgames/core": "0.14.0",
117
+ "@idosgames/module-sdk": "0.3.0",
118
+ "@idosgames/react": "0.2.7",
119
+ "@idosgames/wallet": "0.2.7",
120
120
  "@tanstack/react-query": "5.101.2",
121
121
  "phaser": "4.2.1",
122
122
  "react": "19.2.7",
@@ -132,12 +132,17 @@
132
132
  "type": "template",
133
133
  "engine": "three",
134
134
  "genre": "sandbox",
135
- "summary": "Voxel sandbox (Minecraft-like): walk a first-person world, place and break blocks.",
136
- "description": "A first-person voxel sandbox rendered in Three.js. Players explore a procedurally chunked world, place and break blocks, and move with WASD + mouse-look. Covers any 'build / mine / explore a block world' or open-world sandbox request; the largest of the feature modules.",
135
+ "summary": "Voxel sandbox (Minecraft-like): build a world from a picture + description (with or without AI), then walk it, place and break blocks.",
136
+ "description": "A first-person voxel sandbox rendered in Three.js. The 'Create world' screen turns a reference picture and a text description into a world locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \"voxel-world\") — via a compact WorldSpec recipe: terrain shape, biome zones, 14 structure types (castle, village, bridge…), NPC spawn stubs. Worlds autosave to 'My worlds'. Covers any 'build / mine / explore a block world', open-world sandbox or 'generate a world from a picture' request; the largest of the feature modules.",
137
137
  "provides": [
138
138
  "first-person voxel world exploration",
139
139
  "place and break blocks",
140
140
  "procedural chunked terrain",
141
+ "world generation from a picture + description (WorldSpec recipe)",
142
+ "AI world generation via client.ai (feature \"voxel-world\")",
143
+ "AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)",
144
+ "AI recreates landscapes from pictures (terrain sketch, rivers, biomes)",
145
+ "autosave and 'My worlds' (IndexedDB)",
141
146
  "WASD + mouse-look player controller",
142
147
  "3D block rendering (Three.js)"
143
148
  ],
@@ -147,7 +152,9 @@
147
152
  "minecraft",
148
153
  "3d",
149
154
  "first-person",
150
- "three"
155
+ "three",
156
+ "ai",
157
+ "world-generation"
151
158
  ],
152
159
  "media": {
153
160
  "image": "https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png",
@@ -158,13 +165,14 @@
158
165
  "name": "iDos Games",
159
166
  "url": "https://idosgames.com"
160
167
  },
161
- "version": "0.1.0",
168
+ "version": "0.2.0",
162
169
  "dependencies": {
163
- "@idosgames/module-sdk": "0.2.0",
170
+ "@idosgames/core": "0.14.0",
171
+ "@idosgames/module-sdk": "0.3.0",
164
172
  "three": "0.185.1"
165
173
  },
166
- "fileCount": 39,
167
- "contentHash": "4af383a9c61787c8e20a3087c15d194ef743989f0e1583bbb0db6584d9c872d2"
174
+ "fileCount": 62,
175
+ "contentHash": "cbc5bfa59f373c698f6c11c85ad7255f9092fd05dd38117dc6f57e40a7c3f5b8"
168
176
  },
169
177
  {
170
178
  "id": "game-hud",
@@ -211,10 +219,10 @@
211
219
  ]
212
220
  },
213
221
  "dependencies": {
214
- "@idosgames/core": "0.12.0",
215
- "@idosgames/module-sdk": "0.2.0",
216
- "@idosgames/react": "0.2.5",
217
- "@idosgames/wallet": "0.2.5",
222
+ "@idosgames/core": "0.14.0",
223
+ "@idosgames/module-sdk": "0.3.0",
224
+ "@idosgames/react": "0.2.7",
225
+ "@idosgames/wallet": "0.2.7",
218
226
  "@tanstack/react-query": "5.101.2",
219
227
  "react": "19.2.7",
220
228
  "viem": "2.55.2",
@@ -229,6 +237,10 @@
229
237
  "name": "acquisition-attribution",
230
238
  "description": "Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core) knows where a player came from, and how playtime reaches the publisher's analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite codes / idos_click tokens / Telegram start_param at launch, delivery with the login request, the deferred install match, the universal idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and retention. Use this whenever the user asks about attribution, UTM tags, ad campaigns, install tracking, \"where did this player come from\", invite links, deep links carrying a referral code, session counting, playtime, DAU or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL or localStorage for campaign or referral parameters."
231
239
  },
240
+ {
241
+ "name": "ai-generation-system",
242
+ "description": "Add InApp AI generation to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text generation (optionally with images in — e.g. build a voxel world from a picture the player uploads), image generation and image editing, video, speech (TTS), music and 3D models — every generation is a job the caller waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at runtime in a game built on the iDosGames TS SDK or its templates (board-game, idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels / worlds, voice lines, generated music, 3D props — or touches client.ai, AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature, AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode, isAIGenerationTerminal or parseAIErrorCode — even if they don't name the module."
243
+ },
232
244
  {
233
245
  "name": "authentication",
234
246
  "description": "Log players into a game on the iDosGames TypeScript SDK (@idosgames/core) via client.auth (AuthenticationService): guest/device-id login, two-step email registration with a confirmation code, email login, Google/Telegram login, SSO-code login from idosgames.com, wallet login, password reset, auto login on relaunch, session refresh, logout, and client-side email/password validation. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and asks about logging a player in, sessions, registration, email confirmation codes, resending a code, guest accounts, device-id login, Telegram login, Google login, SSO login, wallet login, forgot/reset password, auto-login, isLoggedIn, or otherwise touches client.auth, AuthenticationService, or AuthContext — even if they don't name the module explicitly."
@@ -299,7 +311,7 @@
299
311
  },
300
312
  {
301
313
  "name": "idosgames-module-contract",
302
- "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention."
314
+ "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention."
303
315
  },
304
316
  {
305
317
  "name": "idosgames-project-structure",
@@ -400,6 +412,14 @@
400
412
  {
401
413
  "name": "user-profile",
402
414
  "description": "Work with the player's own account/session state in the iDosGames TS SDK (@idosgames/core) via client.user (UserService): bootstrap the whole per-player cache at login (ClientState — title config + every module's user state), load the raw inventory snapshot (currencies, items, unstackable instances), read usage-time / session stats, change the username, and delete the account. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants login/session bootstrapping, a profile or account screen, usage-time / playtime tracking, username changes, account deletion, raw inventory reads, or otherwise touches client.user, UserService, ClientState, UserState, UserInventoryState, UsageTimeStats, or client.data.user.state — even if they don't name the module explicitly."
415
+ },
416
+ {
417
+ "name": "voxelcraft-worlds",
418
+ "description": "Build VoxelCraft worlds from a recipe (WorldSpec): from up to 4 reference pictures and a text description, with InApp AI (client.ai, title feature \"voxel-world\") or without it, or written by hand / by an agent. The AI can recreate WHATEVER the pictures show — build objects out of coloured blocks (a plane, a car, a ship, a statue: models made of boxes, cylinders, cones, spheres, lines, with mirror symmetry), rebuild landscapes (a hand-drawn terrain sketch, rivers, biome zones) and combine them into scenes. Covers the WorldSpec JSON format, setting a project's start world (src/worlds/startWorld.ts), the \"Create world\" screen and \"My worlds\" autosave, configuring the \"voxel-world\" AI feature for a title (system prompt, vision model, images, limits, price), and the agent actions generateWorld / loadWorldSpec. Use this whenever the user works on the voxelcraft module and wants a world from a picture, an object from a photo built in blocks, a landscape recreated, a generated / custom / themed voxel map, AI world generation, a start world for their game, or touches WorldSpec, SpecGenerator, VoxelModel, rasterizeModel, parseWorldSpec, buildSpecWithoutAI, CreateWorldUI, createClientWorldAI or VOXEL_WORLD_SYSTEM_PROMPT — even if they don't name the module. Also covers sharing worlds through the Workshop (content type \"voxelcraft.world\")."
419
+ },
420
+ {
421
+ "name": "workshop-system",
422
+ "description": "Let players share what they make — maps, levels, worlds, skins, 3D models, any file — through the Workshop of the iDosGames TypeScript SDK (@idosgames/core client.workshop, WorkshopService): configure content types for a title, publish with files and a thumbnail, set access (free, a price in in-game resources, or \"hold these resources to unlock\" — while held or once), browse the catalog with filters and publisher collections, acquire, download and open content, likes, favorites, following authors, reports, official content. Also covers plugging a game into the ready-made `workshop` module via ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever the user wants user-generated content, a level/map sharing screen, selling maps or skins between players, unlocking content for holders of an item, a creator catalog, or touches client.workshop, WorkshopService, publish, acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions, ContentTypes, ctx.content or the workshop module — even if they don't name the module."
403
423
  }
404
424
  ]
405
425
  }
@@ -45,10 +45,10 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@idosgames/core": "0.12.0",
49
- "@idosgames/module-sdk": "0.2.0",
50
- "@idosgames/react": "0.2.5",
51
- "@idosgames/wallet": "0.2.5",
48
+ "@idosgames/core": "0.14.0",
49
+ "@idosgames/module-sdk": "0.3.0",
50
+ "@idosgames/react": "0.2.7",
51
+ "@idosgames/wallet": "0.2.7",
52
52
  "@tanstack/react-query": "5.101.2",
53
53
  "react": "19.2.7",
54
54
  "three": "0.185.1",
@@ -48,10 +48,10 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@idosgames/core": "0.12.0",
52
- "@idosgames/module-sdk": "0.2.0",
53
- "@idosgames/react": "0.2.5",
54
- "@idosgames/wallet": "0.2.5",
51
+ "@idosgames/core": "0.14.0",
52
+ "@idosgames/module-sdk": "0.3.0",
53
+ "@idosgames/react": "0.2.7",
54
+ "@idosgames/wallet": "0.2.7",
55
55
  "@tanstack/react-query": "5.101.2",
56
56
  "react": "19.2.7",
57
57
  "viem": "2.55.2",
@@ -49,10 +49,10 @@
49
49
  }
50
50
  },
51
51
  "dependencies": {
52
- "@idosgames/core": "0.12.0",
53
- "@idosgames/module-sdk": "0.2.0",
54
- "@idosgames/react": "0.2.5",
55
- "@idosgames/wallet": "0.2.5",
52
+ "@idosgames/core": "0.14.0",
53
+ "@idosgames/module-sdk": "0.3.0",
54
+ "@idosgames/react": "0.2.7",
55
+ "@idosgames/wallet": "0.2.7",
56
56
  "@tanstack/react-query": "5.101.2",
57
57
  "phaser": "4.2.1",
58
58
  "react": "19.2.7",