@idosgames/mcp 0.1.22 → 0.1.24

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 (40) hide show
  1. package/package.json +1 -1
  2. package/registry/host.json +2 -2
  3. package/registry/index.json +173 -140
  4. package/registry/modules/ai.json +5 -5
  5. package/registry/modules/board-game.json +9 -9
  6. package/registry/modules/boosts.json +7 -7
  7. package/registry/modules/character.json +34 -12
  8. package/registry/modules/chat.json +3 -3
  9. package/registry/modules/collections.json +5 -5
  10. package/registry/modules/coop-events.json +5 -5
  11. package/registry/modules/craft.json +5 -5
  12. package/registry/modules/deals.json +5 -5
  13. package/registry/modules/events.json +5 -5
  14. package/registry/modules/friends.json +5 -5
  15. package/registry/modules/idle-rpg.json +93 -54
  16. package/registry/modules/inventory.json +5 -5
  17. package/registry/modules/invite.json +5 -5
  18. package/registry/modules/leaderboard.json +5 -5
  19. package/registry/modules/lootboxes.json +6 -6
  20. package/registry/modules/mailbox.json +5 -5
  21. package/registry/modules/marketplace.json +6 -6
  22. package/registry/modules/mini-game.json +3 -3
  23. package/registry/modules/multiplayer.json +7 -7
  24. package/registry/modules/premium.json +5 -5
  25. package/registry/modules/profile.json +3 -3
  26. package/registry/modules/pvp.json +7 -7
  27. package/registry/modules/quests.json +6 -6
  28. package/registry/modules/rewards.json +5 -5
  29. package/registry/modules/season.json +5 -5
  30. package/registry/modules/store.json +5 -5
  31. package/registry/modules/tutorials.json +3 -3
  32. package/registry/modules/voxelcraft.json +6 -6
  33. package/registry/modules/workshop.json +3 -3
  34. package/registry/skills/blockchain-system.json +1 -1
  35. package/registry/skills/community-marketing-system.json +1 -1
  36. package/registry/skills/idosgames-getting-started.json +1 -1
  37. package/registry/skills/idosgames-ui-kit.json +1 -1
  38. package/registry/skills/mailbox-system.json +1 -1
  39. package/registry/skills/match-system.json +2 -2
  40. package/registry/skills/multiplayer-realtime.json +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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,7 +23,7 @@
23
23
  },
24
24
  {
25
25
  "path": "package.json",
26
- "content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (src/base/) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via 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.10.0\",\n \"@idosgames/core\": \"0.20.0\",\n \"@idosgames/module-sdk\": \"0.8.1\",\n \"@idosgames/react\": \"0.4.2\",\n \"@idosgames/wallet\": \"0.6.1\",\n \"@reown/appkit\": \"1.8.23\",\n \"@reown/appkit-adapter-wagmi\": \"1.8.23\",\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/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (src/base/) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via 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.10.1\",\n \"@idosgames/core\": \"0.21.0\",\n \"@idosgames/module-sdk\": \"0.8.2\",\n \"@idosgames/react\": \"0.4.4\",\n \"@idosgames/wallet\": \"0.6.2\",\n \"@reown/appkit\": \"1.8.23\",\n \"@reown/appkit-adapter-wagmi\": \"1.8.23\",\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",
@@ -75,7 +75,7 @@
75
75
  },
76
76
  {
77
77
  "path": "src/base/login/LoginScreen.tsx",
78
- "content": "import {\n useEffect,\n useMemo,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { beginSsoRedirect, type AuthOptionsResponse } from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"../../env\";\nimport {\n buttonStyle,\n outlined,\n panel,\n screenBackground,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { logoDataUrl } from \"./logo\";\nimport { loginIcons, withIcon } from \"./loginIcons\";\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. Colours, fonts and corner radii are NOT set here: they\n// come from the theme in src/ui.config.ts (the same tokens the lobby and every system use), so a new\n// look is one edit there and sign-in follows it.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithApple, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus\n// forgotPassword/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 (guest only on an open title).\n// Google - the title's own Google sign-in (platform setting) or VITE_IDOS_GOOGLE_CLIENT_ID;\n// the Google Identity script is loaded on demand.\n// Apple - when the title set up Sign in with Apple for the web (a Services ID).\n// Telegram - only inside a Telegram Mini App (it signs in with the app's initData).\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// The title's login mode decides the rest (platform setting: Blockchain -> login mode):\n// Open - every method;\n// AnyWithLinkedWallet - every method except guest (a guest cannot link a wallet);\n// WalletOnly - the wallet and iDos Games only: an iDos Games account plays with a wallet\n// linked to it (one wallet - one account), everything else is refused.\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 и {titleid}-dev.idos.games (её DEV-копия). Сервер примет\n// каждый ТОЛЬКО для этого же тайтла.\nconst GAME_HOST = /^https:\\/\\/[a-z0-9]{8}(-dev)?\\.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 * A remembered wallet session comes back on the next launch by its refresh token (no new\n * signature); the server re-checks the token balance on it when the title's login mode needs one.\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 case \"PLAY_ACCESS_WALLET_LOGIN_ONLY\":\n return \"This game can only be entered with a crypto wallet or your iDos Games account.\";\n case \"PLAY_ACCESS_WALLET_USED_BY_ANOTHER_ACCOUNT\":\n return \"This wallet already plays with another account.\";\n case \"WALLET_UNLINK_COOLDOWN\":\n return \"This wallet was unlinked from another account less than 7 days ago. Try again after the 7 days.\";\n case \"APPLE_EMAIL_MISSING\":\n return \"Apple did not share an e-mail address. Try again and allow it, or use another way to sign in.\";\n case \"PLAY_ACCESS_GUEST_DISABLED\":\n return \"Guest play is off in this game. Sign in to link a crypto wallet.\";\n case \"PLAY_ACCESS_CHECK_FAILED\":\n return \"Could not check your token balance right now. 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\" | \"forgot\" | \"reset\";\n\nconst GOOGLE_SCRIPT = \"https://accounts.google.com/gsi/client\";\nconst APPLE_SCRIPT =\n \"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js\";\n\n/** Loads a provider's script once; resolves when it has run. */\nfunction loadScript(src: string): Promise<void> {\n if (typeof document === \"undefined\")\n return Promise.reject(new Error(\"no document\"));\n const existing = document.querySelector<HTMLScriptElement>(\n `script[src=\"${src}\"]`,\n );\n if (existing?.dataset.loaded === \"1\") return Promise.resolve();\n return new Promise((resolve, reject) => {\n const el = existing ?? document.createElement(\"script\");\n el.addEventListener(\"load\", () => {\n el.dataset.loaded = \"1\";\n resolve();\n });\n el.addEventListener(\"error\", () =>\n reject(new Error(`could not load ${src}`)),\n );\n if (!existing) {\n el.src = src;\n el.async = true;\n document.head.appendChild(el);\n }\n });\n}\n\n/** A random nonce for the provider's request: the server checks the token carries it, then burns it. */\nfunction randomNonce(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Inside a Telegram Mini App the app hands over signed initData - the one thing Telegram login needs. */\nfunction hasTelegramInitData(): boolean {\n const tg = (globalThis as { Telegram?: { WebApp?: { initData?: string } } })\n .Telegram;\n return Boolean(tg?.WebApp?.initData);\n}\n\n/** Minimal Sign in with Apple JS surface - declared here so the template needs no Apple typings. */\ntype AppleIdentity = {\n auth: {\n init(config: {\n clientId: string;\n scope: string;\n redirectURI: string;\n usePopup: boolean;\n nonce?: string;\n }): void;\n signIn(): Promise<{\n authorization: { id_token: string };\n user?: { name?: { firstName?: string; lastName?: string } };\n }>;\n };\n};\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 // Images cannot read CSS variables — the logo and the icons are painted with the resolved theme.\n const theme = useUiKit().config.theme;\n const logo = useMemo(() => logoDataUrl(theme.text), [theme.text]);\n const icons = useMemo(\n () =>\n loginIcons({\n ink: theme.text,\n dim: theme.textDim,\n onPrimary: theme.onGreen,\n }),\n [theme.text, theme.textDim, theme.onGreen],\n );\n\n const [notice, setNotice] = useState<string | null>(null);\n\n // The title's sign-in settings, read before login: the login mode (so the screen never offers a\n // method the server refuses anyway) and which providers the title set up (Google, Apple). Until\n // they arrive the open-title set is shown - the server check is the real barrier, this only\n // keeps dead buttons off the screen.\n const [options, setOptions] = useState<AuthOptionsResponse | null>(null);\n useEffect(() => {\n let active = true;\n void client.auth.getAuthOptions().then((result) => {\n if (active && result.ok) setOptions(result.data);\n });\n return () => {\n active = false;\n };\n }, [client]);\n const loginMode = options?.PlayAccessMode ?? \"Open\";\n const walletOnly = loginMode === \"WalletOnly\";\n const guestAllowed = loginMode === \"Open\";\n const googleClientID =\n ENV_GOOGLE_CLIENT_ID ||\n (options?.Google?.Enabled ? (options.Google.WebClientID ?? \"\") : \"\");\n const appleServiceID = options?.Apple?.Enabled\n ? (options.Apple.ServiceID ?? \"\")\n : \"\";\n const telegram = hasTelegramInitData();\n const sso = isSsoAvailable();\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 = async (): Promise<void> => {\n try {\n await loadScript(GOOGLE_SCRIPT);\n } catch {\n // Handled below: no global means the script is unavailable.\n }\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: googleClientID,\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 const signInWithApple = async (): Promise<void> => {\n setError(null);\n try {\n await loadScript(APPLE_SCRIPT);\n } catch {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const apple = (globalThis as { AppleID?: AppleIdentity }).AppleID;\n if (!apple) {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const nonce = randomNonce();\n apple.auth.init({\n clientId: appleServiceID,\n scope: \"name email\",\n // Must be a return URL registered for this Services ID; with a popup Apple only checks it.\n redirectURI: `${window.location.origin}/`,\n usePopup: true,\n nonce,\n });\n try {\n const response = await apple.auth.signIn();\n const name = [\n response.user?.name?.firstName,\n response.user?.name?.lastName,\n ]\n .filter(Boolean)\n .join(\" \");\n await run(() =>\n client.auth.loginWithApple(response.authorization.id_token, {\n nonce,\n name,\n }),\n );\n } catch {\n // The player closed Apple's window - nothing to report.\n }\n };\n\n /** \"Forgot password\": the server e-mails a code; the next step sets the new password with it. */\n const sendResetCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.forgotPassword(email);\n setBusy(false);\n // Like the resend: the answer does not say whether the address has an account - except \"there\n // is nothing to send with\", which is about the server and must be shown.\n if (!result.ok && result.error === \"EMAIL_SENDER_NOT_CONFIGURED\") {\n setError(humanizeAuthError(result.error));\n return;\n }\n setCode(\"\");\n setPassword(\"\");\n setMode(\"reset\");\n };\n\n const resetPassword = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.resetPassword(\n email,\n code.trim(),\n password,\n );\n setBusy(false);\n if (!result.ok) {\n setError(humanizeAuthError(result.error));\n return;\n }\n setPassword(\"\");\n setNotice(\"Password changed. Sign in with the new one.\");\n setMode(\"email\");\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: ${v.textDim}; opacity: 1; }`}</style>\n <div style={styles.card}>\n <img src={logo} 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: {\n ...styles.button,\n ...styles.primary,\n ...withIcon(icons.wallet, PRIMARY_FILL),\n },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {sso && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.idos) }}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {walletOnly && sso && (\n <p style={styles.hint}>\n Signing in with iDos Games? Link your wallet after that - the\n game is played with a wallet.\n </p>\n )}\n\n {!walletOnly && googleClientID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.google) }}\n onClick={() => void signInWithGoogle()}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n {!walletOnly && appleServiceID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.apple) }}\n onClick={() => void signInWithApple()}\n disabled={busy}\n >\n Continue with Apple\n </button>\n )}\n\n {!walletOnly && telegram && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.telegram) }}\n onClick={() => void run(() => client.auth.loginWithTelegram())}\n disabled={busy}\n >\n Continue with Telegram\n </button>\n )}\n\n {!walletOnly && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.email) }}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n )}\n\n {guestAllowed && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n <span style={styles.inlineIcon}>\n <img src={icons.guest} alt=\"\" width={18} height={18} />\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </span>\n </button>\n )}\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 {!registering && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n setNotice(null);\n }}\n disabled={busy}\n >\n Forgot password?\n </button>\n )}\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 {mode === \"forgot\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n Enter your e-mail - we will send a code to set a new password.\n </p>\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 <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() => void sendResetCode()}\n disabled={busy || !email}\n >\n {busy ? \"Please wait...\" : \"Send the code\"}\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 {mode === \"reset\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n If <strong>{email}</strong> has an account, a code is on its way.\n Enter it and choose a new password.\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=\"Code from the e-mail\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n autoComplete=\"new-password\"\n placeholder=\"New 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={() => void resetPassword()}\n disabled={busy || !code || !password}\n >\n {busy ? \"Please wait...\" : \"Set the new password\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {notice && !error && <p style={styles.hint}>{notice}</p>}\n\n {/* Applies to every provider above: remembered sessions come back by their refresh token. */}\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 ? v.green : v.wellSoft,\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\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// The look is the theme's (src/ui.config.ts, through the UI kit's CSS variables): the screen's\n// gradient, a panel for the card, the kit's green button for the primary action, recessed fields.\n// Change the theme, not these values.\n\n/** The primary button's fill, drawn under the icon layer (see withIcon). */\nconst PRIMARY_FILL = `linear-gradient(180deg, ${v.green} 0%, ${v.greenDeep} 100%)`;\n\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n // Centred by the card's auto margins, not placeItems: a card taller than the screen (a phone in\n // landscape) then starts at the top and scrolls, instead of overflowing above it out of reach.\n padding: \"16px\",\n boxSizing: \"border-box\",\n overflowY: \"auto\",\n background: screenBackground,\n color: v.text,\n fontFamily: v.font,\n fontSize: \"15px\",\n },\n card: {\n ...panel,\n margin: \"auto\",\n width: \"min(360px, 90vw)\",\n boxSizing: \"border-box\",\n padding: \"24px 20px 18px\",\n display: \"grid\",\n gap: \"16px\",\n },\n logo: {\n width: \"160px\",\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: v.textDim,\n fontWeight: 700,\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 boxShadow: `inset 0 0 0 1px ${v.line}`,\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 background: \"#fff\",\n boxShadow: \"0 1px 3px rgba(0, 0, 0, 0.3)\",\n transition: \"left 0.15s\",\n },\n title: {\n ...outlined,\n margin: 0,\n fontSize: \"24px\",\n fontFamily: v.font,\n textAlign: \"center\",\n },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n minHeight: \"46px\",\n borderRadius: v.buttonRadius,\n border: `1px solid ${v.line}`,\n // backgroundColor, not the `background` shorthand: the shorthand would wipe the icon\n // (backgroundImage from ./loginIcons) - here and on the wallet button, whose own default style\n // uses the shorthand.\n backgroundColor: v.wellSoft,\n color: v.text,\n font: \"inherit\",\n fontWeight: 700,\n cursor: \"pointer\",\n },\n // The kit's green button — its text colour and outline follow the theme — with the fill as an image\n // layer, so an icon can sit on top of it.\n primary: {\n ...buttonStyle(\"green\"),\n background: undefined,\n backgroundImage: PRIMARY_FILL,\n border: \"none\",\n width: \"100%\",\n font: \"inherit\",\n fontWeight: 800,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: v.textDim,\n font: \"inherit\",\n fontWeight: 700,\n cursor: \"pointer\",\n },\n inlineIcon: {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: \"8px\",\n },\n input: {\n padding: \"12px 14px\",\n borderRadius: v.buttonRadius,\n border: `1px solid ${v.line}`,\n background: v.well,\n boxShadow: v.wellShadow,\n color: v.text,\n font: \"inherit\",\n outline: \"none\",\n },\n error: { margin: 0, color: v.red, fontWeight: 700, textAlign: \"center\" },\n hint: {\n margin: 0,\n color: v.textDim,\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
78
+ "content": "import {\n useEffect,\n useMemo,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n AuthType,\n beginSsoRedirect,\n type AuthOptionsResponse,\n} from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"../../env\";\nimport {\n buttonStyle,\n outlined,\n panel,\n screenBackground,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { logoDataUrl } from \"./logo\";\nimport { loginIcons, withIcon } from \"./loginIcons\";\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. Colours, fonts and corner radii are NOT set here: they\n// come from the theme in src/ui.config.ts (the same tokens the lobby and every system use), so a new\n// look is one edit there and sign-in follows it.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithApple, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus\n// forgotPassword/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 (guest only on an open title).\n// Google - the title's own Google sign-in (platform setting) or VITE_IDOS_GOOGLE_CLIENT_ID;\n// the Google Identity script is loaded on demand.\n// Apple - when the title set up Sign in with Apple for the web (a Services ID).\n// Telegram - only inside a Telegram Mini App (it signs in with the app's initData).\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// The title's login mode decides the rest (platform setting: Blockchain -> login mode):\n// Open - every method;\n// AnyWithLinkedWallet - every method except guest (a guest cannot link a wallet);\n// WalletOnly - the wallet and iDos Games only: an iDos Games account plays with a wallet\n// linked to it (one wallet - one account), everything else is refused.\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 и {titleid}-dev.idos.games (её DEV-копия). Сервер примет\n// каждый ТОЛЬКО для этого же тайтла.\nconst GAME_HOST = /^https:\\/\\/[a-z0-9]{8}(-dev)?\\.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 * A remembered wallet session comes back on the next launch by its refresh token (no new\n * signature); the server re-checks the token balance on it when the title's login mode needs one.\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 case \"PLAY_ACCESS_WALLET_LOGIN_ONLY\":\n return \"This game can only be entered with a crypto wallet or your iDos Games account.\";\n case \"PLAY_ACCESS_WALLET_USED_BY_ANOTHER_ACCOUNT\":\n return \"This wallet already plays with another account.\";\n case \"WALLET_UNLINK_COOLDOWN\":\n return \"This wallet was unlinked from another account less than 7 days ago. Try again after the 7 days.\";\n case \"APPLE_EMAIL_MISSING\":\n return \"Apple did not share an e-mail address. Try again and allow it, or use another way to sign in.\";\n case \"PLAY_ACCESS_GUEST_DISABLED\":\n return \"Guest play is off in this game. Sign in to link a crypto wallet.\";\n case \"PLAY_ACCESS_CHECK_FAILED\":\n return \"Could not check your token balance right now. 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\" | \"forgot\" | \"reset\";\n\nconst GOOGLE_SCRIPT = \"https://accounts.google.com/gsi/client\";\nconst APPLE_SCRIPT =\n \"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js\";\n\n/** Loads a provider's script once; resolves when it has run. */\nfunction loadScript(src: string): Promise<void> {\n if (typeof document === \"undefined\")\n return Promise.reject(new Error(\"no document\"));\n const existing = document.querySelector<HTMLScriptElement>(\n `script[src=\"${src}\"]`,\n );\n if (existing?.dataset.loaded === \"1\") return Promise.resolve();\n return new Promise((resolve, reject) => {\n const el = existing ?? document.createElement(\"script\");\n el.addEventListener(\"load\", () => {\n el.dataset.loaded = \"1\";\n resolve();\n });\n el.addEventListener(\"error\", () =>\n reject(new Error(`could not load ${src}`)),\n );\n if (!existing) {\n el.src = src;\n el.async = true;\n document.head.appendChild(el);\n }\n });\n}\n\n/** A random nonce for the provider's request: the server checks the token carries it, then burns it. */\nfunction randomNonce(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Inside a Telegram Mini App the app hands over signed initData - the one thing Telegram login needs. */\nfunction hasTelegramInitData(): boolean {\n const tg = (globalThis as { Telegram?: { WebApp?: { initData?: string } } })\n .Telegram;\n return Boolean(tg?.WebApp?.initData);\n}\n\n/** Minimal Sign in with Apple JS surface - declared here so the template needs no Apple typings. */\ntype AppleIdentity = {\n auth: {\n init(config: {\n clientId: string;\n scope: string;\n redirectURI: string;\n usePopup: boolean;\n nonce?: string;\n }): void;\n signIn(): Promise<{\n authorization: { id_token: string };\n user?: { name?: { firstName?: string; lastName?: string } };\n }>;\n };\n};\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 // Images cannot read CSS variables — the logo and the icons are painted with the resolved theme.\n const theme = useUiKit().config.theme;\n const logo = useMemo(() => logoDataUrl(theme.text), [theme.text]);\n const icons = useMemo(\n () =>\n loginIcons({\n ink: theme.text,\n dim: theme.textDim,\n onPrimary: theme.onGreen,\n }),\n [theme.text, theme.textDim, theme.onGreen],\n );\n\n const [notice, setNotice] = useState<string | null>(null);\n\n // The title's sign-in settings, read before login: the login mode (so the screen never offers a\n // method the server refuses anyway) and which providers the title set up (Google, Apple). Until\n // they arrive the open-title set is shown - the server check is the real barrier, this only\n // keeps dead buttons off the screen.\n const [options, setOptions] = useState<AuthOptionsResponse | null>(null);\n useEffect(() => {\n let active = true;\n void client.auth.getAuthOptions().then((result) => {\n if (active && result.ok) setOptions(result.data);\n });\n return () => {\n active = false;\n };\n }, [client]);\n const loginMode = options?.PlayAccessMode ?? \"Open\";\n const walletOnly = loginMode === \"WalletOnly\";\n const guestAllowed = loginMode === \"Open\";\n const googleClientID =\n ENV_GOOGLE_CLIENT_ID ||\n (options?.Google?.Enabled ? (options.Google.WebClientID ?? \"\") : \"\");\n const appleServiceID = options?.Apple?.Enabled\n ? (options.Apple.ServiceID ?? \"\")\n : \"\";\n const telegram = hasTelegramInitData();\n const sso = isSsoAvailable();\n\n /**\n * «Continue with iDos Games»: сначала уже имеющаяся сессия, и только без неё — за новым кодом на\n * сайт (решение владельца 02.10.2026). Новый вход — это новая сессия в движке, а лимит устройств\n * тайтла по умолчанию одно: лишний вход вытесняет живую сессию этого же игрока (другую вкладку,\n * телефон) и стоит ухода со страницы. Живой refresh-токен продлевается одним запросом.\n *\n * Без живого токена `autoLogin()` для входа через iDos Games просто отказывает — в гостя он не\n * уводит (`replay` такой вход не повторяет), поэтому дальше идёт обычный уход на /sso.\n */\n const continueWithIdosGames = async (): Promise<void> => {\n if (client.auth.lastAuthType === AuthType.iDosGames) {\n setBusy(true);\n setError(null);\n const resumed = await client.auth.autoLogin();\n if (resumed.ok) {\n onAuthenticated();\n return;\n }\n setBusy(false);\n }\n beginSsoRedirect({ titleID: client.titleID });\n };\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 = async (): Promise<void> => {\n try {\n await loadScript(GOOGLE_SCRIPT);\n } catch {\n // Handled below: no global means the script is unavailable.\n }\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: googleClientID,\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 const signInWithApple = async (): Promise<void> => {\n setError(null);\n try {\n await loadScript(APPLE_SCRIPT);\n } catch {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const apple = (globalThis as { AppleID?: AppleIdentity }).AppleID;\n if (!apple) {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const nonce = randomNonce();\n apple.auth.init({\n clientId: appleServiceID,\n scope: \"name email\",\n // Must be a return URL registered for this Services ID; with a popup Apple only checks it.\n redirectURI: `${window.location.origin}/`,\n usePopup: true,\n nonce,\n });\n try {\n const response = await apple.auth.signIn();\n const name = [\n response.user?.name?.firstName,\n response.user?.name?.lastName,\n ]\n .filter(Boolean)\n .join(\" \");\n await run(() =>\n client.auth.loginWithApple(response.authorization.id_token, {\n nonce,\n name,\n }),\n );\n } catch {\n // The player closed Apple's window - nothing to report.\n }\n };\n\n /** \"Forgot password\": the server e-mails a code; the next step sets the new password with it. */\n const sendResetCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.forgotPassword(email);\n setBusy(false);\n // Like the resend: the answer does not say whether the address has an account - except \"there\n // is nothing to send with\", which is about the server and must be shown.\n if (!result.ok && result.error === \"EMAIL_SENDER_NOT_CONFIGURED\") {\n setError(humanizeAuthError(result.error));\n return;\n }\n setCode(\"\");\n setPassword(\"\");\n setMode(\"reset\");\n };\n\n const resetPassword = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.resetPassword(\n email,\n code.trim(),\n password,\n );\n setBusy(false);\n if (!result.ok) {\n setError(humanizeAuthError(result.error));\n return;\n }\n setPassword(\"\");\n setNotice(\"Password changed. Sign in with the new one.\");\n setMode(\"email\");\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: ${v.textDim}; opacity: 1; }`}</style>\n <div style={styles.card}>\n <img src={logo} 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: {\n ...styles.button,\n ...styles.primary,\n ...withIcon(icons.wallet, PRIMARY_FILL),\n },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {sso && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.idos) }}\n onClick={() => void continueWithIdosGames()}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {walletOnly && sso && (\n <p style={styles.hint}>\n Signing in with iDos Games? Link your wallet after that - the\n game is played with a wallet.\n </p>\n )}\n\n {!walletOnly && googleClientID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.google) }}\n onClick={() => void signInWithGoogle()}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n {!walletOnly && appleServiceID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.apple) }}\n onClick={() => void signInWithApple()}\n disabled={busy}\n >\n Continue with Apple\n </button>\n )}\n\n {!walletOnly && telegram && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.telegram) }}\n onClick={() => void run(() => client.auth.loginWithTelegram())}\n disabled={busy}\n >\n Continue with Telegram\n </button>\n )}\n\n {!walletOnly && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.email) }}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n )}\n\n {guestAllowed && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n <span style={styles.inlineIcon}>\n <img src={icons.guest} alt=\"\" width={18} height={18} />\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </span>\n </button>\n )}\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 {!registering && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n setNotice(null);\n }}\n disabled={busy}\n >\n Forgot password?\n </button>\n )}\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 {mode === \"forgot\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n Enter your e-mail - we will send a code to set a new password.\n </p>\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 <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() => void sendResetCode()}\n disabled={busy || !email}\n >\n {busy ? \"Please wait...\" : \"Send the code\"}\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 {mode === \"reset\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n If <strong>{email}</strong> has an account, a code is on its way.\n Enter it and choose a new password.\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=\"Code from the e-mail\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n autoComplete=\"new-password\"\n placeholder=\"New 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={() => void resetPassword()}\n disabled={busy || !code || !password}\n >\n {busy ? \"Please wait...\" : \"Set the new password\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {notice && !error && <p style={styles.hint}>{notice}</p>}\n\n {/* Applies to every provider above: remembered sessions come back by their refresh token. */}\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 ? v.green : v.wellSoft,\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\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// The look is the theme's (src/ui.config.ts, through the UI kit's CSS variables): the screen's\n// gradient, a panel for the card, the kit's green button for the primary action, recessed fields.\n// Change the theme, not these values.\n\n/** The primary button's fill, drawn under the icon layer (see withIcon). */\nconst PRIMARY_FILL = `linear-gradient(180deg, ${v.green} 0%, ${v.greenDeep} 100%)`;\n\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n // Centred by the card's auto margins, not placeItems: a card taller than the screen (a phone in\n // landscape) then starts at the top and scrolls, instead of overflowing above it out of reach.\n padding: \"16px\",\n boxSizing: \"border-box\",\n overflowY: \"auto\",\n background: screenBackground,\n color: v.text,\n fontFamily: v.font,\n fontSize: \"15px\",\n },\n card: {\n ...panel,\n margin: \"auto\",\n width: \"min(360px, 90vw)\",\n boxSizing: \"border-box\",\n padding: \"24px 20px 18px\",\n display: \"grid\",\n gap: \"16px\",\n },\n logo: {\n width: \"160px\",\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: v.textDim,\n fontWeight: 700,\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 boxShadow: `inset 0 0 0 1px ${v.line}`,\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 background: \"#fff\",\n boxShadow: \"0 1px 3px rgba(0, 0, 0, 0.3)\",\n transition: \"left 0.15s\",\n },\n title: {\n ...outlined,\n margin: 0,\n fontSize: \"24px\",\n fontFamily: v.font,\n textAlign: \"center\",\n },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n minHeight: \"46px\",\n borderRadius: v.buttonRadius,\n border: `1px solid ${v.line}`,\n // backgroundColor, not the `background` shorthand: the shorthand would wipe the icon\n // (backgroundImage from ./loginIcons) - here and on the wallet button, whose own default style\n // uses the shorthand.\n backgroundColor: v.wellSoft,\n color: v.text,\n font: \"inherit\",\n fontWeight: 700,\n cursor: \"pointer\",\n },\n // The kit's green button — its text colour and outline follow the theme — with the fill as an image\n // layer, so an icon can sit on top of it.\n primary: {\n ...buttonStyle(\"green\"),\n background: undefined,\n backgroundImage: PRIMARY_FILL,\n border: \"none\",\n width: \"100%\",\n font: \"inherit\",\n fontWeight: 800,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: v.textDim,\n font: \"inherit\",\n fontWeight: 700,\n cursor: \"pointer\",\n },\n inlineIcon: {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: \"8px\",\n },\n input: {\n padding: \"12px 14px\",\n borderRadius: v.buttonRadius,\n border: `1px solid ${v.line}`,\n background: v.well,\n boxShadow: v.wellShadow,\n color: v.text,\n font: \"inherit\",\n outline: \"none\",\n },\n error: { margin: 0, color: v.red, fontWeight: 700, textAlign: \"center\" },\n hint: {\n margin: 0,\n color: v.textDim,\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
79
79
  },
80
80
  {
81
81
  "path": "src/base/login/logo.ts",