@idosgames/mcp 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -120,11 +120,11 @@ var SERVER_INSTRUCTIONS = [
120
120
  "It does NOT read or change any live Title's data.",
121
121
  "To configure a live Title's settings (TitlePublicConfiguration) or generate assets",
122
122
  "(image / audio / 3D / video / text), that is a SEPARATE server \u2014 the iDosGames Title-configuration MCP:",
123
- "HTTP JSON-RPC at POST https://site.idosgames.com/api/v2/mcp, authenticated with an X-MCP-API-Key",
124
- "header (the publisher issues the key per Title on platform.idosgames.com), tools get_<field> /",
125
- "save_<field> and generate_*. Connect it as an HTTP MCP server; keep the key out of committed config",
126
- 'via env expansion \u2014 .mcp.json: {"idosgames-title": {"type": "http", "url":',
127
- '"https://site.idosgames.com/api/v2/mcp", "headers": {"X-MCP-API-Key": "${IDOS_MCP_API_KEY}"}}}.',
123
+ "HTTP JSON-RPC at POST https://site.idosgames.com/api/v2/mcp, tools get_<field> / save_<field>",
124
+ "and generate_*. Authorization is OAuth 2.1: connect it as a plain HTTP MCP server with NO headers",
125
+ "and no API key \u2014 the client discovers the authorization server from the 401 challenge, registers",
126
+ "itself, and opens a browser where the publisher picks the Titles and permissions to grant.",
127
+ '.mcp.json: {"idosgames-title": {"type": "http", "url": "https://site.idosgames.com/api/v2/mcp"}}.',
128
128
  "Rule of thumb: game CODE \u2192 this server; a Title's live config DATA and generated ASSETS \u2192 the backend v2/mcp server."
129
129
  ].join(" ");
130
130
  function createServer() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  {
13
13
  "path": "package.json",
14
- "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.1.13\",\n \"@idosgames/core\": \"0.7.0\",\n \"@idosgames/module-sdk\": \"0.1.8\",\n \"@idosgames/react\": \"0.2.0\",\n \"@idosgames/wallet\": \"0.2.0\",\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"
14
+ "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.1.15\",\n \"@idosgames/core\": \"0.9.0\",\n \"@idosgames/module-sdk\": \"0.1.10\",\n \"@idosgames/react\": \"0.2.2\",\n \"@idosgames/wallet\": \"0.2.2\",\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"
15
15
  },
16
16
  {
17
17
  "path": "src/config.ts",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  {
29
29
  "path": "src/LoginScreen.tsx",
30
- "content": "import { 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\ntype Mode = \"menu\" | \"email\";\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\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(result.error ?? \"Sign-in failed. Please try again.\");\n setBusy(false);\n };\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 void run(() =>\n registering\n ? client.auth.registerWithEmail(email, password)\n : client.auth.loginWithEmail(email, password),\n )\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 {/* 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};\n"
30
+ "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(humanizeAuthError(result.error) ?? \"Sign-in failed. Please try again.\");\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(humanizeAuthError(result.error) ?? \"Sign-up failed. Please try again.\");\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(() => client.auth.confirmEmailRegistration(email, code))\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 ? `Send again in ${resendIn}s` : \"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"
31
31
  },
32
32
  {
33
33
  "path": "src/logo.ts",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedFromCommit": "f160bbae4de0d0e10b1b41d4f02a6c47ffd34fe3",
2
+ "generatedFromCommit": "8d95686552c20e3df4d8c650bd44d52e0bfcba10",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.7.0",
5
- "@idosgames/wallet": "0.2.0",
6
- "@idosgames/module-sdk": "0.1.8",
7
- "@idosgames/react": "0.2.0",
8
- "@idosgames/app-shell": "0.1.13"
4
+ "@idosgames/core": "0.9.0",
5
+ "@idosgames/wallet": "0.2.2",
6
+ "@idosgames/module-sdk": "0.1.10",
7
+ "@idosgames/react": "0.2.2",
8
+ "@idosgames/app-shell": "0.1.15"
9
9
  },
10
10
  "host": {
11
11
  "id": "host-starter",
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "version": "0.1.0",
48
48
  "dependencies": {
49
- "@idosgames/core": "0.7.0",
50
- "@idosgames/module-sdk": "0.1.8",
51
- "@idosgames/react": "0.2.0",
52
- "@idosgames/wallet": "0.2.0",
49
+ "@idosgames/core": "0.9.0",
50
+ "@idosgames/module-sdk": "0.1.10",
51
+ "@idosgames/react": "0.2.2",
52
+ "@idosgames/wallet": "0.2.2",
53
53
  "@tanstack/react-query": "5.101.2",
54
54
  "react": "19.2.7",
55
55
  "three": "0.185.1",
@@ -92,10 +92,10 @@
92
92
  },
93
93
  "version": "0.1.0",
94
94
  "dependencies": {
95
- "@idosgames/core": "0.7.0",
96
- "@idosgames/module-sdk": "0.1.8",
97
- "@idosgames/react": "0.2.0",
98
- "@idosgames/wallet": "0.2.0",
95
+ "@idosgames/core": "0.9.0",
96
+ "@idosgames/module-sdk": "0.1.10",
97
+ "@idosgames/react": "0.2.2",
98
+ "@idosgames/wallet": "0.2.2",
99
99
  "@tanstack/react-query": "5.101.2",
100
100
  "phaser": "4.2.1",
101
101
  "react": "19.2.7",
@@ -138,7 +138,7 @@
138
138
  },
139
139
  "version": "0.1.0",
140
140
  "dependencies": {
141
- "@idosgames/module-sdk": "0.1.8",
141
+ "@idosgames/module-sdk": "0.1.10",
142
142
  "three": "0.185.1"
143
143
  },
144
144
  "fileCount": 38
@@ -147,7 +147,7 @@
147
147
  "skills": [
148
148
  {
149
149
  "name": "authentication",
150
- "description": "Log players into a game on the iDosGames TypeScript SDK (@idosgames/core) via client.auth (AuthenticationService): guest/device-id login, email register & login, Google/Telegram/platform-token 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, guest accounts, device-id login, Telegram login, Google login, platform-token login, forgot/reset password, auto-login, isLoggedIn, or otherwise touches client.auth, AuthenticationService, or AuthContext — even if they don't name the module explicitly."
150
+ "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."
151
151
  },
152
152
  {
153
153
  "name": "blockchain-system",
@@ -245,6 +245,10 @@
245
245
  "name": "premium-system",
246
246
  "description": "Build a premium / subscription / VIP-tier system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.premium (PremiumService): load premium tier definitions, load the player's active subscriptions, activate a free trial, purchase a premium tier with virtual/item cost, or complete a real-money IAP subscription purchase (App Store / Google Play receipt validation). This is the player's subscription/IAP tier that other modules (Store cost discounts via ResourceConsume.PremiumDiscounts, reward/lootbox grant multipliers via ResourceGrant.PremiumTiers, segment gates via SegmentGate.MinPremiumTier) read to unlock perks. Use whenever the user wants a subscription/VIP/battle-pass-tier paywall, IAP receipt validation, trial flows, or touches client.premium, PremiumService, PremiumDefinition, MaxActiveTier, or premium discounts/multipliers — even if they don't name the module explicitly."
247
247
  },
248
+ {
249
+ "name": "purchase-system",
250
+ "description": "Sell real-money in-app purchases (IAP) in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.purchase (PurchaseService): load the store product catalog with per-player availability, send a store receipt to the backend for verification, grant the product, restore purchases after a reinstall, and read the player's purchase state (ownership, counters, lifetime spend). Covers Apple App Store and Google Play receipts, consumables / non-consumables / subscriptions, and what happens when the store refunds a purchase. Use whenever the user wants real-money packs, \"remove ads\", a VIP subscription, a restore-purchases button, receipt validation, or touches client.purchase, PurchaseService, IapStore, ValidatePurchase, or IapProductDefinition — even if they don't name the module explicitly."
251
+ },
248
252
  {
249
253
  "name": "quest-system",
250
254
  "description": "Build a quest / daily-task system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.quest (QuestService): load quest and cycle definitions, load the player's quest progress state, add progress toward a metric, claim a completed quest's reward, claim a points-track milestone reward, claim a group-completion (grand) reward, and refresh cycles (dailies/ weeklies) forward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest screens, task lists, objective/progress trackers, battle-pass-style points tracks, milestone reward ladders, quest-group completion bonuses, or otherwise touches client.quest, QuestService, QuestDefinitions, UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they don't name the module explicitly."
@@ -37,10 +37,10 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.7.0",
41
- "@idosgames/module-sdk": "0.1.8",
42
- "@idosgames/react": "0.2.0",
43
- "@idosgames/wallet": "0.2.0",
40
+ "@idosgames/core": "0.9.0",
41
+ "@idosgames/module-sdk": "0.1.10",
42
+ "@idosgames/react": "0.2.2",
43
+ "@idosgames/wallet": "0.2.2",
44
44
  "@tanstack/react-query": "5.101.2",
45
45
  "react": "19.2.7",
46
46
  "three": "0.185.1",
@@ -37,10 +37,10 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.7.0",
41
- "@idosgames/module-sdk": "0.1.8",
42
- "@idosgames/react": "0.2.0",
43
- "@idosgames/wallet": "0.2.0",
40
+ "@idosgames/core": "0.9.0",
41
+ "@idosgames/module-sdk": "0.1.10",
42
+ "@idosgames/react": "0.2.2",
43
+ "@idosgames/wallet": "0.2.2",
44
44
  "@tanstack/react-query": "5.101.2",
45
45
  "phaser": "4.2.1",
46
46
  "react": "19.2.7",
@@ -78,7 +78,7 @@
78
78
  },
79
79
  {
80
80
  "path": "data/characterConfig.ts",
81
- "content": "import type { IDosGamesClient } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n StatScalingFactor?: number;\n CostScalingFactor?: number;\n BaseCostResource?: {\n Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] };\n };\n}\n\nexport interface StatsPreset {\n Stats?: Record<string, StatDefinition>;\n}\n\nexport interface CharacterPresetBindings {\n Stats?: PresetBinding;\n Levels?: PresetBinding;\n Equipment?: PresetBinding;\n}\n\nexport interface CharacterDefinition {\n CharacterID?: string;\n Presets?: CharacterPresetBindings;\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n Stats?: Record<string, StatDefinition>;\n Classification?: { ClassID?: string; RarityID?: string };\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\n Unlock?: { UnlockedByDefault?: boolean };\n}\n\nexport interface CharacterPresetRegistry {\n Stats?: Record<string, StatsPreset>;\n Equipment?: Record<string, EquipmentPreset>;\n}\n\nexport interface CharacterConfig {\n Definitions?: Record<string, CharacterDefinition>;\n Presets?: CharacterPresetRegistry;\n}\n\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\n return client.data.config.getSection<CharacterConfig>(\"Character\") ?? {};\n}\n\nexport interface SlotInfo {\n slotID: string;\n minCharacterLevel: number;\n}\n\n/**\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveSlots(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): SlotInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const presetSlots = binding?.PresetID\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\n return Object.entries(slots).map(([slotID, rule]) => ({\n slotID,\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\n }));\n}\n\nexport interface StatInfo {\n statID: string;\n displayName: string;\n maxLevel?: number;\n baseValue: number;\n scaling: number;\n costScaling: number;\n costAmount: number;\n costCurrency?: string;\n}\n\n/**\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\n * StatID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveStats(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const presetStats = binding?.PresetID\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\n return Object.entries(stats).map(([statID, rule]) => {\n const entry = rule.BaseCostResource?.Standard?.Entries?.[0];\n return {\n statID,\n displayName: rule.DisplayName ?? statID,\n maxLevel: rule.MaxLevel,\n baseValue: rule.BaseStatValue ?? 0,\n scaling: rule.StatScalingFactor ?? 0,\n costScaling: rule.CostScalingFactor ?? 0,\n costAmount: entry?.Amount ?? 0,\n costCurrency: entry?.CurrencyID,\n };\n });\n}\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\nfunction mergeByKey<T>(\n preset: Record<string, T> | undefined,\n inline: Record<string, T> | undefined,\n remove: string[] | undefined,\n): Record<string, T> {\n if (!preset) return inline ?? {};\n const result: Record<string, T> = { ...preset, ...inline };\n for (const key of remove ?? []) delete result[key];\n return result;\n}\n\n/** Effective stat value at a given level (port of CharacterStatMath.ComputeValueAtLevel). */\nexport function computeStatValue(stat: StatInfo, level: number): number {\n if (level <= 1) return stat.baseValue;\n return stat.baseValue * (1 + stat.scaling * (level - 1));\n}\n\n/** Cost to upgrade a stat TO `nextLevel` (port of CharacterStatMath.ComputeUpgradeCost). */\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\n if (stat.costAmount <= 0) return 0;\n const scaled = stat.costAmount * (1 + stat.costScaling * (nextLevel - 1));\n return Math.max(0, Math.round(scaled));\n}\n\nexport type Availability = \"owned\" | \"available\" | \"locked\";\n\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\n * (first level-up creates the model); locked = must be unlocked. */\nexport function resolveAvailability(\n def: CharacterDefinition | undefined,\n hasModel: boolean,\n): Availability {\n if (hasModel) return \"owned\";\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\n return \"locked\";\n}\n"
81
+ "content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\r\nimport { curveMultiplier, evaluateCurve, roundAmount } from \"@idosgames/core\";\r\n\r\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\r\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\r\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\r\n// see Core/Presets/Models/PresetBinding.cs on the backend).\r\n\r\nexport interface PresetBinding {\r\n PresetID?: string;\r\n Remove?: string[];\r\n}\r\n\r\nexport interface EquipmentSlotRule {\r\n SlotID?: string;\r\n MinCharacterLevel?: number;\r\n AllowedItemTags?: string[];\r\n AllowedRarityIDs?: string[];\r\n MaxItemLevel?: number;\r\n MinItemLevel?: number;\r\n}\r\n\r\nexport interface EquipmentPreset {\r\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\r\n}\r\n\r\nexport interface PriceOption {\r\n Cost?: { Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] } };\r\n}\r\n\r\nexport interface StatDefinition {\r\n StatID?: string;\r\n DisplayName?: string;\r\n MaxLevel?: number;\r\n BaseStatValue?: number;\r\n /** Growth of the value over the stat's own level; the step is numbered from **0**. */\r\n ValueCurve?: ScalarCurveSpec;\r\n /** Cost curve; the step is the TARGET level, numbered from 1. */\r\n CostCurve?: ScalarCurveSpec;\r\n /** Ways to pay; key = the option id. Replaced the removed `BaseCostResource`. */\r\n PriceOptions?: Record<string, PriceOption>;\r\n}\r\n\r\nexport interface StatsPreset {\r\n Stats?: Record<string, StatDefinition>;\r\n}\r\n\r\nexport interface CharacterPresetBindings {\r\n Stats?: PresetBinding;\r\n Levels?: PresetBinding;\r\n Equipment?: PresetBinding;\r\n}\r\n\r\nexport interface CharacterDefinition {\r\n CharacterID?: string;\r\n Presets?: CharacterPresetBindings;\r\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\r\n Stats?: Record<string, StatDefinition>;\r\n Classification?: { ClassID?: string; RarityID?: string };\r\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\r\n Unlock?: { UnlockedByDefault?: boolean };\r\n}\r\n\r\nexport interface CharacterPresetRegistry {\r\n Stats?: Record<string, StatsPreset>;\r\n Equipment?: Record<string, EquipmentPreset>;\r\n}\r\n\r\nexport interface CharacterConfig {\r\n Definitions?: Record<string, CharacterDefinition>;\r\n Presets?: CharacterPresetRegistry;\r\n}\r\n\r\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\r\n return client.data.config.getSection<CharacterConfig>(\"Character\") ?? {};\r\n}\r\n\r\nexport interface SlotInfo {\r\n slotID: string;\r\n minCharacterLevel: number;\r\n}\r\n\r\n/**\r\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\r\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\r\n */\r\nexport function resolveSlots(\r\n config: CharacterConfig,\r\n def: CharacterDefinition | undefined,\r\n): SlotInfo[] {\r\n if (!def) return [];\r\n const binding = def.Presets?.Equipment;\r\n const presetSlots = binding?.PresetID\r\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\r\n : undefined;\r\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\r\n return Object.entries(slots).map(([slotID, rule]) => ({\r\n slotID,\r\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\r\n }));\r\n}\r\n\r\nexport interface StatInfo {\r\n statID: string;\r\n displayName: string;\r\n maxLevel?: number;\r\n baseValue: number;\r\n valueCurve?: ScalarCurveSpec;\r\n costCurve?: ScalarCurveSpec;\r\n costAmount: number;\r\n costCurrency?: string;\r\n}\r\n\r\n/**\r\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\r\n * StatID (preset base + inline override/add, Remove drops keys).\r\n */\r\nexport function resolveStats(\r\n config: CharacterConfig,\r\n def: CharacterDefinition | undefined,\r\n): StatInfo[] {\r\n if (!def) return [];\r\n const binding = def.Presets?.Stats;\r\n const presetStats = binding?.PresetID\r\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\r\n : undefined;\r\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\r\n return Object.entries(stats).map(([statID, rule]) => {\r\n // The first option's first entry, mirroring the server's \"default cost\" for shop-like\r\n // displays (`PriceOptionSelector.DefaultCost`): the player has not chosen a way to pay yet.\r\n const option = Object.values(rule.PriceOptions ?? {})[0];\r\n const entry = option?.Cost?.Standard?.Entries?.[0];\r\n return {\r\n statID,\r\n displayName: rule.DisplayName ?? statID,\r\n maxLevel: rule.MaxLevel,\r\n baseValue: rule.BaseStatValue ?? 0,\r\n valueCurve: rule.ValueCurve,\r\n costCurve: rule.CostCurve,\r\n costAmount: entry?.Amount ?? 0,\r\n costCurrency: entry?.CurrencyID,\r\n };\r\n });\r\n}\r\n\r\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\r\nfunction mergeByKey<T>(\r\n preset: Record<string, T> | undefined,\r\n inline: Record<string, T> | undefined,\r\n remove: string[] | undefined,\r\n): Record<string, T> {\r\n if (!preset) return inline ?? {};\r\n const result: Record<string, T> = { ...preset, ...inline };\r\n for (const key of remove ?? []) delete result[key];\r\n return result;\r\n}\r\n\r\n/**\r\n * Effective stat value at a given level the shared curve, evaluated exactly as the\r\n * server does it in `PvPBattleEngine.CalculateStats`.\r\n *\r\n * The step is the stat level with **firstStep = 0**: a stat the player never upgraded is\r\n * level 0 and is worth the plain base. This used to be a third hand-written copy of the\r\n * formula here, and it was wrong in two ways at once — it assumed the multiplicative\r\n * shape (the server's is whatever the publisher configured) and counted from level 1, so\r\n * every stat was displayed one step behind what the battle actually used.\r\n */\r\nexport function computeStatValue(stat: StatInfo, level: number): number {\r\n return evaluateCurve(stat.valueCurve, stat.baseValue, level, 0);\r\n}\r\n\r\n/**\r\n * Cost to upgrade a stat TO `nextLevel`. The step is the TARGET level, numbered from 1,\r\n * and the total is rounded UP once — the platform's single rounding convention. The old\r\n * `Math.round` here disagreed with the server by one unit on half the levels.\r\n */\r\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\r\n if (stat.costAmount <= 0) return 0;\r\n return roundAmount(stat.costAmount * curveMultiplier(stat.costCurve, nextLevel, 1));\r\n}\r\n\r\nexport type Availability = \"owned\" | \"available\" | \"locked\";\r\n\r\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\r\n * (first level-up creates the model); locked = must be unlocked. */\r\nexport function resolveAvailability(\r\n def: CharacterDefinition | undefined,\r\n hasModel: boolean,\r\n): Availability {\r\n if (hasModel) return \"owned\";\r\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\r\n return \"locked\";\r\n}\r\n"
82
82
  },
83
83
  {
84
84
  "path": "env.ts",
@@ -37,7 +37,7 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/module-sdk": "0.1.8",
40
+ "@idosgames/module-sdk": "0.1.10",
41
41
  "three": "0.185.1"
42
42
  },
43
43
  "files": [
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "authentication",
3
- "description": "Log players into a game on the iDosGames TypeScript SDK (@idosgames/core) via client.auth (AuthenticationService): guest/device-id login, email register & login, Google/Telegram/platform-token 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, guest accounts, device-id login, Telegram login, Google login, platform-token login, forgot/reset password, auto-login, isLoggedIn, or otherwise touches client.auth, AuthenticationService, or AuthContext — even if they don't name the module explicitly.",
4
- "content": "---\nname: authentication\ndescription: >-\n Log players into a game on the iDosGames TypeScript SDK (@idosgames/core)\n via client.auth (AuthenticationService): guest/device-id login, email\n register & login, Google/Telegram/platform-token login, password reset, auto\n login on relaunch, session refresh, logout, and client-side email/password\n validation. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and asks about logging a player\n in, sessions, registration, guest accounts, device-id login, Telegram login,\n Google login, platform-token login, forgot/reset password, auto-login,\n isLoggedIn, or otherwise touches client.auth, AuthenticationService, or\n AuthContext — even if they don't name the module explicitly.\n---\n\n# Authentication (iDosGames TS SDK)\n\nThe Authentication module is how a player gets a session. It's the one module\nevery other feature depends on: nothing else on `client` works until a login\nmethod has succeeded. Unlike feature modules, most of its methods don't return\na narrow per-feature payload — they return the **entire post-login bootstrap\nstate** (`ClientState`: title config + the player's full `User` state),\nbecause a successful login is also \"give me everything the client needs to\nrender.\"\n\nThis skill is for **using** production login flows, not for porting or\nextending the service. If a login is rejected, that's the backend enforcing a\nrule (bad credentials, duplicate email, banned account) — surface the error,\ndon't try to reproduce the check client-side.\n\n## Remember me\n\n`setRememberSession(remember)` decides whether a successful login is written to storage. Call it\n**before** a `login*` method — it is read when that login completes, and it applies to whichever\nprovider runs next.\n\n```ts\nclient.auth.setRememberSession(rememberCheckbox); // default: true\nconst result = await client.auth.loginWithDeviceID();\n```\n\n- **On** (the default, and how every release before core 0.1.3 behaved) — the session is persisted,\n so `autoLogin()` signs the player back in on the next launch.\n- **Off** — nothing is persisted and any previously remembered session is dropped, so the next\n launch opens on the login screen. The current session is NOT weakened: the credentials stay in\n memory for this tab, so the transport's automatic 401 re-login still works.\n\n`logout()` ends the session and clears the persisted one, so a reload does not walk back into the\naccount the player just left. (Before core 0.1.3 it left storage untouched and a reload silently\nsigned them back in.)\n\nA wallet session is never remembered either way — a fresh signature is required on every launch.\n\n## Mental model\n\n- **Guest login** (`loginWithDeviceID`) — anonymous account keyed off a\n per-device ID the platform adapter generates/stores. Zero-friction first\n launch; no credentials to lose, but also nothing to recover if the device ID\n is gone (reinstall, new device).\n- **Linked login methods** — `loginWithEmail` / `registerWithEmail`,\n `loginWithGoogle`, `loginWithTelegram`, `loginWithPlatformToken`. These tie\n the account to a real identity so the player can resume it elsewhere.\n- **`autoLogin()`** picks up where the player left off, but only for methods\n that don't need a fresh externally-issued token: it replays `loginWithEmail`\n if the last successful login was Email (saved password), `loginWithDeviceID`\n if it was Device/None, and `loginWithTelegram` if it was Telegram (its\n `initData` is re-read live from the Telegram WebApp bridge each time, not\n stored). For Google/Facebook/GooglePlay/platform-token, there's no stored\n credential to replay — `autoLogin()` returns `reason: \"client\"` instead of\n guessing, because DeviceID and email/platform logins hash to **different\n backend UserIDs** (see Gotchas), so silently falling back to a guest login\n would strand the player on an unrelated empty account. What `autoLogin()`\n has to replay is controlled by **\"remember me\"** — see below. Call this on app\n start instead of hand-rolling \"which method did they use last,\" but be ready\n to handle its failure by re-running the platform's sign-in flow.\n- **Session refresh is automatic.** The HTTP transport calls\n `refreshSession()` itself when a request comes back 401 — it re-runs\n `autoLogin()` under the hood and retries once. You almost never call\n `refreshSession()` directly; it's documented here for completeness, not as a\n method you wire up in app code.\n- **Static validators** (`AuthenticationService.isValidEmail`,\n `AuthenticationService.isValidPasswordLength`) are plain synchronous\n functions, not calls to the backend — use them to validate a form before\n spending a network round-trip on a login/register call that will fail\n server-side anyway. They mirror, but do not replace, the server's own\n checks — the server always re-validates.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any other auth.* login method\n\nclient.auth.isLoggedIn; // true once a login call has succeeded\n```\n\nEvery other module's methods require this to have succeeded first — without a\nsession they return `{ ok: false, reason: \"unauthorized\" }` rather than\nthrowing. There is one `client` per player; don't share it across sessions.\nAuthentication requests carry no Bearer ticket at all — login/register/reset\nare the one family of calls that work before you have a session.\n\n## Methods\n\nAll login/register/reset methods return `Promise<OperationResult<T>>`: either\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing Telegram initData or empty platform token),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe reason code the backend returned, e.g. `\"INCORRECT_EMAIL_OR_PASSWORD\"`,\n`\"EMAIL_ALREADY_EXISTS\"` — see Gotchas for the verbatim set).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |\n| `loginWithDeviceID()` | Anonymous/guest login keyed off the device ID. | `ClientState` |\n| `loginWithTelegram()` | Login using the Telegram Mini App's init data. | `ClientState` |\n| `loginWithEmail(email, password)` | Login with email + password. | `ClientState` |\n| `registerWithEmail(email, password)` | Create an account with email + password, then log in. | `ClientState` |\n| `loginWithGoogle(googleIDToken)` | Login with a Google ID token. | `ClientState` |\n| `loginWithPlatformToken(authToken)` | Login with an iDosGames platform auth token. | `ClientState` |\n| `forgotPassword(email)` | Trigger a password-reset email (a 6-digit code). | `SuccessResponse` |\n| `resetPassword(email, resetToken, password)` | Complete a password reset using the emailed code. | `SuccessResponse` |\n| `autoLogin()` | Replay the last-used auth method (Email/Device/None/Telegram only — fails with `reason: \"client\"` for Google/Facebook/GooglePlay/platform-token). | `ClientState` |\n| `refreshSession()` | Re-authenticate and return a fresh session ticket; used internally by the transport on 401. | `string \\| null` (not an `OperationResult`) |\n| `logout()` | Clear the session, forget the saved login method, and reset the local cache. Synchronous, no network call. | `void` |\n| `setRememberSession(remember)` | \"Remember me\" — whether the NEXT login is persisted for `autoLogin()`. Default `true`. Call before a `login*` method. | `void` |\n| `AuthenticationService.isValidEmail(email)` | Static, synchronous client-side format check. | `boolean` |\n| `AuthenticationService.isValidPasswordLength(pw)` | Static, synchronous length check (8–100 chars). | `boolean` |\n\nNon-Promise instance getters (read directly, no `await`):\n\n| Getter | Returns |\n| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `client.auth.context` | `AuthContext \\| null` — `{ userID, clientSessionTicket, clientSessionTicketExpiration, platformUserID?, platformAuthToken?, platformAuthTokenExpiration? }`, or `null` if never logged in. |\n| `client.auth.isLoggedIn` | `true` once `context` has a non-empty `userID` and `clientSessionTicket`. |\n| `client.auth.lastAuthType` | The persisted `AuthType` (`\"None\" \\| \"Device\" \\| \"Email\" \\| \"iDosGames\" \\| \"Facebook\" \\| \"Google\" \\| \"GooglePlay\" \\| \"Telegram\"`) from the previous successful login, read from local storage. |\n\nOn a successful login/register call, the SDK mirrors the full `ClientState`\ninto `client.data` (title config + user state) — same cache every other\nmodule reads from — and emits events. `email`/`password` are persisted to\nlocal storage **only** after a successful `loginWithEmail` or\n`registerWithEmail` call, so `autoLogin()` has something to replay. A leftover\nusername you pass in is only a fallback — Google logins use the Google\nprofile name, platform-token logins use the platform profile name, and if\nnone is available anywhere the backend assigns a generated one (see Gotchas).\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n- `auth:requestSent` → `void` — fired at the start of every login/reset call (before the network round-trip). One exception: `loginWithPlatformToken` validates its token argument first, so an empty-token `reason: \"client\"` failure doesn't fire it.\n- `auth:loggedIn` → `void` — fired after a login/register call succeeds and `ClientState` has been applied to the cache.\n- `auth:loggedOut` → `void` — fired synchronously by `logout()`.\n- `auth:unauthorized` → `void` — transport-level, fired by the HTTP layer only when a 401 could **not** be transparently recovered (the automatic refresh was unavailable, already attempted, or failed). A 401 that the refresh-and-retry rescues never emits it. Not emitted by `AuthenticationService` itself.\n- `user:clientStateReceived` → `ClientState` — fired whenever a full client state is applied (login, or any other flow that re-fetches it).\n- `user:stateUpdated` → `void` — fired whenever the cached `User` half of `ClientState` is replaced, which includes every successful login.\n- `user:anyUpdated` → `void` — coarsest \"something in the user cache changed\" signal; also fires on login.\n\n```ts\nconst off = client.on(\"auth:loggedIn\", () => {\n console.log(\"logged in as\", client.auth.context?.userID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Guest login on first launch\n\n```ts\nconst result = await client.auth.loginWithDeviceID();\nif (!result.ok) return showError(result.error ?? result.reason);\n// client.data now has the full ClientState; render the game.\n```\n\n### Try auto-login first, fall back appropriately\n\n```ts\nasync function bootstrap() {\n const result = await client.auth.autoLogin();\n if (result.ok) return; // resumed the last session\n if (result.reason === \"client\" && client.auth.lastAuthType !== \"None\") {\n // Last session used Google/Facebook/GooglePlay/a platform token — autoLogin\n // can't replay that silently. Re-run that platform's sign-in flow and call\n // the matching login method (e.g. loginWithGoogle) with a fresh token.\n return reauthenticateViaPlatformSDK(client.auth.lastAuthType);\n }\n showError(result.error ?? result.reason);\n}\n```\n\n`autoLogin()` safely self-replays Email, Device/None, and Telegram — call it\nonce on app start for those. For anything else it deliberately does **not**\nfall back to a guest login (see Gotchas) — treat its `reason: \"client\"`\nfailure as \"go get a fresh token,\" not as a generic error to toast.\n\n### Register a new account with email\n\n```ts\nif (!AuthenticationService.isValidEmail(email)) {\n return showFieldError(\"email\", \"Enter a valid email address.\");\n}\nif (!AuthenticationService.isValidPasswordLength(password)) {\n return showFieldError(\"password\", \"Password must be 8–100 characters.\");\n}\n\nconst result = await client.auth.registerWithEmail(email, password);\nif (!result.ok) return showError(result.error ?? result.reason); // e.g. \"EMAIL_ALREADY_EXISTS\"\n// logged in immediately on success; email/password saved for autoLogin().\n```\n\nEmail is matched case-insensitively — the backend trims and lowercases it\nbefore every lookup and write, so `Player@Mail.com` and `player@mail.com`\ncollide on the same account.\n\n### Upgrade a guest account to an email account\n\nThe SDK has no dedicated \"link email to this guest\" call — `registerWithEmail`\nalways mints its **own** account (a fresh UserID derived from the email, via a\ndifferent hash than the device-id account uses). To move a guest forward\nwithout losing their progress, do it through your own game-side flow (e.g. a\ncloud script that copies/merges state) rather than assuming registration\ncarries the guest's inventory along; don't build a UI that implies it will.\n\n### Forgot / reset password\n\n```ts\nconst sent = await client.auth.forgotPassword(email);\nif (!sent.ok) return showError(sent.error ?? sent.reason);\n// If an account exists for this email, a 6-digit reset code was emailed,\n// valid for 5 minutes. The response looks identical whether or not the\n// email is registered (see Gotchas) — don't tell the player \"no such account.\"\n\nconst reset = await client.auth.resetPassword(email, resetToken, newPassword);\nif (!reset.ok) return showError(reset.error ?? reset.reason);\n// password changed; call loginWithEmail with the new password next\n```\n\n### Handle a rejected login\n\n```ts\nconst result = await client.auth.loginWithEmail(email, password);\nif (!result.ok) {\n switch (result.reason) {\n case \"connection\":\n return offerRetry();\n case \"throttled\":\n return; // ignore — same call already in flight/just ran\n case \"client\":\n case \"server\":\n default:\n return showError(result.error ?? \"Login failed.\");\n }\n}\n```\n\n### Log out\n\n```ts\nclient.auth.logout(); // synchronous — clears context + resets client.data cache\n// route to login screen; no network call is made\n```\n\n## Gotchas\n\n- **`autoLogin()` deliberately refuses to guess for federated/platform\n logins.** The backend derives DeviceID UserIDs from\n `SHA256(platform + device + deviceID)` and email/platform-linked UserIDs\n from `SHA256(email + titleID)` — two different hashes with no server-side\n link between them. Falling back to `loginWithDeviceID()` for a\n Google/Facebook/GooglePlay/platform-token session would silently resolve to\n a different, empty account, not \"safely resume as a guest.\" If\n `lastAuthType` is one of those, `autoLogin()` returns\n `{ ok: false, reason: \"client\" }` instead; the app must obtain a fresh token\n from that platform's SDK and call the matching `login*` method itself.\n- **The exact `error` strings on `reason: \"server\"` are backend-defined\n codes/messages**, not prose meant for direct display — surface them through\n your own copy/localization layer rather than showing them raw. Verified\n values from the backend for the flows above: `\"INVALID_INPUT_DATA\"`\n (missing/blank required field) and `\"INCORRECT_EMAIL_OR_PASSWORD\"` (bad\n credentials **or** unknown email — the backend intentionally doesn't\n distinguish the two, so don't tell the player \"no such account\") and\n `\"EMAIL_ALREADY_EXISTS\"` (register with a taken email) are shared\n `MessageCode` enum values also used elsewhere in the backend;\n `\"RATE_LIMIT_EXCEEDED\"` / `\"OPERATION_IN_PROGRESS\"` (per-account login lock\n — see below) are the same enum family. Google/platform-token logins instead\n return ad-hoc string literals specific to that flow, e.g. `\"BANNED_GLOBAL\"`\n (platform account is banned), `\"GOOGLE_ACCOUNT_CONFLICT\"` (email already\n linked to a _different_ Google account), `\"INVALID_GOOGLE_TOKEN\"`\n (bad/expired Google ID token) — see\n [references/data-model.md](references/data-model.md) for the full list.\n Telegram login instead surfaces plain sentences like `\"Invalid Telegram\ndata\"` / an internal `\"Telegram auth_date is stale\"` condition (initData\n older than 24h or timestamped implausibly in the future) — treat any\n non-uppercase-code string as an opaque message, not something to\n pattern-match on.\n- **Per-account login is rate-limited server-side, independent of the SDK's\n own 600 ms throttle.** Repeated `loginWithEmail` attempts for the same\n account inside a ~1-second window come back `\"RATE_LIMIT_EXCEEDED\"`; a login\n already being processed for that account comes back\n `\"OPERATION_IN_PROGRESS\"`. This is a brute-force guard, not a bug — don't\n retry-loop past it.\n- **Telegram login needs the platform adapter's init data.** `loginWithTelegram()`\n calls `platform.getTelegramInitDataRaw()` first; if that returns falsy (not\n running inside Telegram, or the adapter doesn't support it), the call fails\n client-side with `reason: \"client\"` before any network request — no point\n retrying without fixing the environment. Server-side, the init data is\n rejected if its `auth_date` is missing, more than 24 hours old, or more than\n 5 minutes in the future — a Telegram Mini App that's been idle a long time\n may need a fresh WebApp launch, not just a retry. On success it's recorded\n under its own `AuthType.Telegram` (not `Device`) precisely so `autoLogin()`\n replays `loginWithTelegram()` again rather than a device-id login.\n- **`loginWithPlatformToken` requires a non-empty token.** An empty/missing\n `authToken` fails immediately with `reason: \"client\"`. On success it's\n recorded under `AuthType.iDosGames` (not a generic \"platform\" label) — that's\n what `lastAuthType` reports afterward.\n- **A username you pass in is a last resort, not a guarantee.** For Google and\n platform-token logins, the backend prefers the identity provider's own\n profile name over any client-supplied username on every login (not just the\n first) — so a locally cached display name can be silently overwritten by the\n linked account's name. If no name is available from any source at\n registration time, the backend assigns a generated one\n (`\"BraveTiger482\"`-style: adjective + noun + 2–4 digit number) rather than\n leaving it blank.\n- **`forgotPassword` never reveals whether an email is registered.** Unknown\n emails and known emails both return `{ ok: true }` with no email actually\n sent for the unknown case — this is a deliberate anti-enumeration measure,\n not a bug to work around. The emailed reset code is a 6-digit number valid\n for 5 minutes; requesting again inside a 60-second cooldown silently\n no-ops (still returns `ok: true`, no second email). `resetPassword` allows\n at most 5 wrong-code attempts before the code is invalidated server-side and\n a fresh `forgotPassword` call is required — the `error` on a wrong-but-not-final\n attempt reports the remaining count (e.g. `\"Invalid reset token. 3 attempts\nremaining\"`).\n- **Email credentials are only saved on success.** `loginWithEmail` and\n `registerWithEmail` persist email+password to local storage (scoped by\n `titleID`) only after `fetchAndApplyClientState` succeeds — a failed login\n doesn't overwrite previously saved credentials, and a fresh install has\n nothing saved until the first successful email login.\n- **`refreshSession()` has a re-entrancy guard.** If it's already running\n (e.g. two requests 401 at nearly the same time), a second call returns `null`\n immediately instead of recursing — this exists to avoid deadlocking the\n retry, so don't assume a `null` result means the session is actually dead.\n- **`logout()` forgets the saved login method too (changed in core 0.1.3).** It\n nulls the auth context, wipes the cached user state and title-config bundle,\n clears `lastAuthType` and any saved email/password, and emits\n `auth:loggedOut` — so a later `autoLogin()` has nothing to resume. Before\n 0.1.3 storage survived a logout and the next launch silently signed the\n player back into the account they had just left. If you want a sign-out the\n player is remembered through, don't call `logout()`.\n- **`refreshSession()` and `logout()` don't return `OperationResult`.**\n `refreshSession()` resolves to `string | null` (the fresh session ticket, or\n `null` on failure/no saved method); `logout()` is synchronous `void`. Don't\n branch on `.ok` for these two.\n- **`GameLoop` is excluded from the login `ClientState`.** Board/stage state\n and its definitions are fetched separately per-stage by the GameLoop\n feature, not bundled into every login response.\n- **Guard against double-submit.** Each call fires a real request; a\n double-tapped \"Log in\" button can fire twice. Firing the same auth endpoint\n again inside the SDK's own throttle window (default 600 ms) comes back\n `reason: \"throttled\"` rather than duplicating the call, but disable the\n control while a call is in flight rather than relying on that — and note the\n server has its own, stricter per-account lock on top (see above).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — request/response wire\nshapes, the exact backend validation and error-code rules for each login/reset\npath, UserID derivation, and the default-username generator. Read it when you\nneed to reason about _why_ a specific `error` string came back, or when\nbuilding a password-reset or registration UI that needs to match the backend's\ntiming/attempt rules exactly.\n",
3
+ "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.",
4
+ "content": "---\nname: authentication\ndescription: >-\n Log players into a game on the iDosGames TypeScript SDK (@idosgames/core)\n via client.auth (AuthenticationService): guest/device-id login, two-step\n email registration with a confirmation code, email login, Google/Telegram\n login, SSO-code login from idosgames.com, wallet login, password reset, auto\n login on relaunch, session refresh, logout, and client-side email/password\n validation. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and asks about logging a player\n in, sessions, registration, email confirmation codes, resending a code,\n guest accounts, device-id login, Telegram login, Google login, SSO login,\n wallet login, forgot/reset password, auto-login, isLoggedIn, or otherwise\n touches client.auth, AuthenticationService, or AuthContext — even if they\n don't name the module explicitly.\n---\n\n# Authentication (iDosGames TS SDK)\n\nThe Authentication module is how a player gets a session. It's the one module\nevery other feature depends on: nothing else on `client` works until a login\nmethod has succeeded. Unlike feature modules, most of its methods don't return\na narrow per-feature payload — they return the **entire post-login bootstrap\nstate** (`ClientState`: title config + the player's full `User` state),\nbecause a successful login is also \"give me everything the client needs to\nrender.\"\n\nThis skill is for **using** production login flows, not for porting or\nextending the service. If a login is rejected, that's the backend enforcing a\nrule (bad credentials, duplicate email, banned account) — surface the error,\ndon't try to reproduce the check client-side.\n\n## Remember me\n\n`setRememberSession(remember)` decides whether a successful login is written to storage. Call it\n**before** a `login*` method — it is read when that login completes, and it applies to whichever\nprovider runs next.\n\n```ts\nclient.auth.setRememberSession(rememberCheckbox); // default: true\nconst result = await client.auth.loginWithDeviceID();\n```\n\n- **On** (the default, and how every release before core 0.1.3 behaved) — the session is persisted,\n so `autoLogin()` signs the player back in on the next launch.\n- **Off** — nothing is persisted and any previously remembered session is dropped, so the next\n launch opens on the login screen. The current session is NOT weakened: the credentials stay in\n memory for this tab, so the transport's automatic 401 re-login still works.\n\n`logout()` ends the session and clears the persisted one, so a reload does not walk back into the\naccount the player just left. (Before core 0.1.3 it left storage untouched and a reload silently\nsigned them back in.)\n\nA wallet session is never remembered either way — a fresh signature is required on every launch.\n\n## Mental model\n\n- **Guest login** (`loginWithDeviceID`) — anonymous account keyed off a\n per-device ID the platform adapter generates/stores. Zero-friction first\n launch; no credentials to lose, but also nothing to recover if the device ID\n is gone (reinstall, new device).\n- **Linked login methods** — `loginWithEmail` / `registerWithEmail`,\n `loginWithGoogle`, `loginWithTelegram`, `loginWithSsoCode`. These tie\n the account to a real identity so the player can resume it elsewhere.\n- **`autoLogin()`** picks up where the player left off, but only for methods\n that don't need a fresh externally-issued token: it replays `loginWithEmail`\n if the last successful login was Email (saved password), `loginWithDeviceID`\n if it was Device/None, and `loginWithTelegram` if it was Telegram (its\n `initData` is re-read live from the Telegram WebApp bridge each time, not\n stored). For Google/Facebook/GooglePlay/platform-token, there's no stored\n credential to replay — `autoLogin()` returns `reason: \"client\"` instead of\n guessing, because DeviceID and email/platform logins hash to **different\n backend UserIDs** (see Gotchas), so silently falling back to a guest login\n would strand the player on an unrelated empty account. What `autoLogin()`\n has to replay is controlled by **\"remember me\"** — see below. Call this on app\n start instead of hand-rolling \"which method did they use last,\" but be ready\n to handle its failure by re-running the platform's sign-in flow.\n- **Session refresh is automatic.** The HTTP transport calls\n `refreshSession()` itself when a request comes back 401 — it re-runs\n `autoLogin()` under the hood and retries once. You almost never call\n `refreshSession()` directly; it's documented here for completeness, not as a\n method you wire up in app code.\n- **Static validators** (`AuthenticationService.isValidEmail`,\n `AuthenticationService.isValidPasswordLength`) are plain synchronous\n functions, not calls to the backend — use them to validate a form before\n spending a network round-trip on a login/register call that will fail\n server-side anyway. They mirror, but do not replace, the server's own\n checks — the server always re-validates.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any other auth.* login method\n\nclient.auth.isLoggedIn; // true once a login call has succeeded\n```\n\nEvery other module's methods require this to have succeeded first — without a\nsession they return `{ ok: false, reason: \"unauthorized\" }` rather than\nthrowing. There is one `client` per player; don't share it across sessions.\nAuthentication requests carry no Bearer ticket at all — login/register/reset\nare the one family of calls that work before you have a session.\n\n## Methods\n\nAll login/register/reset methods return `Promise<OperationResult<T>>`: either\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing Telegram initData or empty platform token),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe reason code the backend returned, e.g. `\"INCORRECT_EMAIL_OR_PASSWORD\"`,\n`\"INVALID_VERIFICATION_CODE\"` — see Gotchas for the verbatim set).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |\n| `loginWithDeviceID()` | Anonymous/guest login keyed off the device ID. | `ClientState` |\n| `loginWithTelegram()` | Login using the Telegram Mini App's init data. | `ClientState` |\n| `loginWithEmail(email, password)` | Login with email + password. | `ClientState` |\n| `registerWithEmail(email, password)` | **Start** a registration. Only mails a code — never returns a session, see below. | `EmailRegistrationOutcome` |\n| `confirmEmailRegistration(email, code)` | Finish the registration with the code from the email, creating the player. | `ClientState` |\n| `resendVerificationCode(email)` | Mail a fresh code. Resolves successfully whatever happened — except when there is no usable sender at all (`EMAIL_SENDER_NOT_CONFIGURED`). | `void` |\n| `requestWalletChallenge(networkID, address)` | Ask for the message the player's wallet must sign. Step one of wallet login. | `WalletChallengeResponse` |\n| `loginWithWallet(networkID, address, signature)` | Exchange the signed challenge for a title session. | `ClientState` |\n| `loginWithGoogle(googleIDToken)` | Login with a Google ID token. | `ClientState` |\n| `loginWithSsoCode(code)` | Exchange a one-time code from idosgames.com for a title session. **Web only** — see below. | `ClientState` |\n| `forgotPassword(email)` | Trigger a password-reset email (a 6-digit code). | `SuccessResponse` |\n| `resetPassword(email, resetToken, password)` | Complete a password reset using the emailed code. | `SuccessResponse` |\n| `autoLogin()` | Replay the last-used auth method (Email/Device/None/Telegram only — fails with `reason: \"client\"` for Google/Facebook/GooglePlay/platform-token). | `ClientState` |\n| `refreshSession()` | Re-authenticate and return a fresh session ticket; used internally by the transport on 401. | `string \\| null` (not an `OperationResult`) |\n| `logout()` | Clear the session, forget the saved login method, and reset the local cache. Synchronous, no network call. | `void` |\n| `setRememberSession(remember)` | \"Remember me\" — whether the NEXT login is persisted for `autoLogin()`. Default `true`. Call before a `login*` method. | `void` |\n| `AuthenticationService.isValidEmail(email)` | Static, synchronous client-side format check. | `boolean` |\n| `AuthenticationService.isValidPasswordLength(pw)` | Static, synchronous length check (8–100 chars). | `boolean` |\n\n⚠ **Each title has its OWN password.** The same address in two games means two independent\npasswords: resetting in one game does not change the login for the other. The reason is that the\nreset e-mail goes out through the PUBLISHER's SMTP — with one shared password, one publisher's mail\nserver would control access to every other publisher's game. So don't tell the player \"the same\npassword as in your other game\", and don't reuse stored credentials across titles.\nThe IDENTITY is still shared: Google, Apple and the idosgames.com sign-in recognise the same person\nin every game. Identity answers \"who is this\", not \"what proves they may sign in\".\n\n⚠ **Signing in through idosgames.com from a NATIVE build is not in this SDK.** `beginSsoRedirect` /\n`loginWithSsoCode` are the web half: the browser comes back to the same page with the code in the\nURL fragment. In a packaged Android/iOS build the browser is a separate program that tells the app\nnothing, so that flow uses a request the game polls for instead (`BeginNativeSsoRequest` /\n`PollNativeSsoRequest`), and it is implemented in the Unity SDK. Don't reach for `loginWithSsoCode`\nfrom a native wrapper — it has no way to receive the code.\n\n⚠ **That native flow now has a SHORT CODE, and a native client should use it.** `BeginNativeSsoRequest`\naccepts `UseUserCode: true` and then answers with `UserCode` (`WDJB-MJHT`) plus `VerificationUri`.\nThe game displays the code; the player opens the plain URL and types it there. The older shape — the\ngame opening `…/sso?title=X&request=<handle>` and the page confirming on its own — still works for\nalready-shipped builds, but **a request created with a code refuses that path**, so a build must do\none or the other, never a mix.\n\nThe reason is not convenience. In the older shape the handle travels IN THE ADDRESS, so the link can\nbe sent to somebody else: the attacker creates the request on their own device, the victim opens the\nlink and confirms, and the attacker's game gets a session as the victim. PKCE does not help (the\nverifier belongs to whoever created the request). With a code there is nothing in the URL to forward\n— the request is named only by what is on the device's own screen. `GetAuthOptions` reports\n`NativeSso.UserCodeSupported` and `NativeSso.PollIntervalSeconds`; ask before assuming, and follow\nthe interval or the poll quota will refuse you.\n\n⚠ **The code is shown, the secret is not.** Players read codes off screens, over shoulders and out\nof screenshots — so the code alone must never buy a session. Polling keeps requiring the request\nsecret, which never leaves the game.\n\nNon-Promise instance getters (read directly, no `await`):\n\n| Getter | Returns |\n| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `client.auth.context` | `AuthContext \\| null` — `{ userID, clientSessionTicket, clientSessionTicketExpiration, platformUserID?, platformAuthToken?, platformAuthTokenExpiration? }`, or `null` if never logged in. |\n| `client.auth.isLoggedIn` | `true` once `context` has a non-empty `userID` and `clientSessionTicket`. |\n| `client.auth.lastAuthType` | The persisted `AuthType` (`\"None\" \\| \"Device\" \\| \"Email\" \\| \"iDosGames\" \\| \"Facebook\" \\| \"Google\" \\| \"GooglePlay\" \\| \"Telegram\"`) from the previous successful login, read from local storage. |\n\nOn a successful login/register call, the SDK mirrors the full `ClientState`\ninto `client.data` (title config + user state) — same cache every other\nmodule reads from — and emits events. `email`/`password` are persisted to\nlocal storage **only** after a successful `loginWithEmail` or\n`confirmEmailRegistration` call, so `autoLogin()` has something to replay.\n⚠ Note it is the CONFIRMATION, not `registerWithEmail`, that persists them:\nregistration alone creates no account, so there is nothing to replay yet (the\npassword is carried over from the first step internally). A leftover\nusername you pass in is only a fallback — Google logins use the Google\nprofile name, platform-token logins use the platform profile name, and if\nnone is available anywhere the backend assigns a generated one (see Gotchas).\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n- `auth:requestSent` → `void` — fired at the start of every login/reset call (before the network round-trip). One exception: `loginWithSsoCode` validates its argument first, so an empty-code `reason: \"client\"` failure doesn't fire it.\n- `auth:loggedIn` → `void` — fired after a login/register call succeeds and `ClientState` has been applied to the cache.\n- `auth:loggedOut` → `void` — fired synchronously by `logout()`.\n- `auth:unauthorized` → `void` — transport-level, fired by the HTTP layer only when a 401 could **not** be transparently recovered (the automatic refresh was unavailable, already attempted, or failed). A 401 that the refresh-and-retry rescues never emits it. Not emitted by `AuthenticationService` itself.\n- `user:clientStateReceived` → `ClientState` — fired whenever a full client state is applied (login, or any other flow that re-fetches it).\n- `user:stateUpdated` → `void` — fired whenever the cached `User` half of `ClientState` is replaced, which includes every successful login.\n- `user:anyUpdated` → `void` — coarsest \"something in the user cache changed\" signal; also fires on login.\n\n```ts\nconst off = client.on(\"auth:loggedIn\", () => {\n console.log(\"logged in as\", client.auth.context?.userID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Guest login on first launch\n\n```ts\nconst result = await client.auth.loginWithDeviceID();\nif (!result.ok) return showError(result.error ?? result.reason);\n// client.data now has the full ClientState; render the game.\n```\n\n### Try auto-login first, fall back appropriately\n\n```ts\nasync function bootstrap() {\n const result = await client.auth.autoLogin();\n if (result.ok) return; // resumed the last session\n if (result.reason === \"client\" && client.auth.lastAuthType !== \"None\") {\n // Last session used Google/Facebook/GooglePlay/a platform token — autoLogin\n // can't replay that silently. Re-run that platform's sign-in flow and call\n // the matching login method (e.g. loginWithGoogle) with a fresh token.\n return reauthenticateViaPlatformSDK(client.auth.lastAuthType);\n }\n showError(result.error ?? result.reason);\n}\n```\n\n`autoLogin()` safely self-replays Email, Device/None, and Telegram — call it\nonce on app start for those. For anything else it deliberately does **not**\nfall back to a guest login (see Gotchas) — treat its `reason: \"client\"`\nfailure as \"go get a fresh token,\" not as a generic error to toast.\n\n### Register a new account with email\n\n```ts\nif (!AuthenticationService.isValidEmail(email)) {\n return showFieldError(\"email\", \"Enter a valid email address.\");\n}\nif (!AuthenticationService.isValidPasswordLength(password)) {\n return showFieldError(\"password\", \"Password must be 8–100 characters.\");\n}\n\nconst started = await client.auth.registerWithEmail(email, password);\nif (!started.ok) return showError(started.error ?? started.reason);\n\n// No account yet — a code was mailed. Show a code screen; the session arrives from confirm.\nconst done = await client.auth.confirmEmailRegistration(email, codeFromPlayer);\nif (!done.ok) return showError(done.error ?? done.reason);\n```\n\n⚠ **`registerWithEmail` never creates an account, and success does not mean the address was\nfree.**\n\nRegistration always stores the attempt and mails a 6-digit code; the player is created by\n`confirmEmailRegistration`. Building a screen that assumes an immediate session will leave players\nstaring at a spinner that never resolves.\n\nConfirmation is **platform behaviour, not a title setting** — there is no flag to switch it off,\nand no `verificationRequired` branch to write. Only the numbers are configurable (code lifetime,\nattempt cap, resend cooldown).\n\nThe answer is also deliberately **identical for a free and for a taken address** — there is no\n`EMAIL_ALREADY_EXISTS` on this path any more. Telling them apart would turn the registration form\ninto a way of checking whether a given person has an account here, which is exactly what the\npassword-recovery form goes out of its way to avoid. The owner of a taken address gets a \"you\nalready have an account\" e-mail instead, without a code.\n\nOne failure on this path **is** reported: `EMAIL_SENDER_NOT_CONFIGURED`, when the server has no\nusable mail sender at all. It says nothing about the address — the same answer comes back for a\nfree one, a taken one and a typo — and it exists because confirmation cannot be switched off: with\nno sender, e-mail registration does not work at all, and answering \"code sent\" would hide a broken\ndeployment behind a normal-looking screen. Show it as a server-side problem, not as the player's\nmistake, and do not offer a \"resend\" — the resend refuses the same way. A letter that merely fails\nto leave is a different case and still answers success.\n\nEvery way of getting the code wrong — no such registration, expired, mismatched — answers with the\nsame `INVALID_VERIFICATION_CODE`, for the same reason. Do not branch on it.\n\n`resendVerificationCode` mails a **new** code: only its hash is stored, so the previous one cannot\nbe re-sent and stops working. Rate-limit the button in the UI (the server's cooldown is reported as\n`resendCooldownSeconds`) — the server answers successfully even when it sent nothing.\n\nEmail is matched case-insensitively — the backend trims and lowercases it\nbefore every lookup and write, so `Player@Mail.com` and `player@mail.com`\ncollide on the same account.\n\n### Upgrade a guest account to an email account\n\nThe SDK has no dedicated \"link email to this guest\" call — `registerWithEmail`\nalways mints its **own** account (a fresh UserID derived from the email, via a\ndifferent hash than the device-id account uses). To move a guest forward\nwithout losing their progress, do it through your own game-side flow (e.g. a\ncloud script that copies/merges state) rather than assuming registration\ncarries the guest's inventory along; don't build a UI that implies it will.\n\n### Forgot / reset password\n\n```ts\nconst sent = await client.auth.forgotPassword(email);\nif (!sent.ok) return showError(sent.error ?? sent.reason);\n// If an account exists for this email, a 6-digit reset code was emailed,\n// valid for 5 minutes. The response looks identical whether or not the\n// email is registered (see Gotchas) — don't tell the player \"no such account.\"\n\nconst reset = await client.auth.resetPassword(email, resetToken, newPassword);\nif (!reset.ok) return showError(reset.error ?? reset.reason);\n// password changed; call loginWithEmail with the new password next\n```\n\n### Handle a rejected login\n\n```ts\nconst result = await client.auth.loginWithEmail(email, password);\nif (!result.ok) {\n switch (result.reason) {\n case \"connection\":\n return offerRetry();\n case \"throttled\":\n return; // ignore — same call already in flight/just ran\n case \"client\":\n case \"server\":\n default:\n return showError(result.error ?? \"Login failed.\");\n }\n}\n```\n\n### Log out\n\n```ts\nclient.auth.logout(); // synchronous — clears context + resets client.data cache\n// route to login screen; no network call is made\n```\n\n## Gotchas\n\n- **`autoLogin()` deliberately refuses to guess for federated/platform\n logins.** The backend derives DeviceID UserIDs from\n `SHA256(platform + device + deviceID)` and email/platform-linked UserIDs\n from `SHA256(email + titleID)` — two different hashes with no server-side\n link between them. Falling back to `loginWithDeviceID()` for a\n Google/Facebook/GooglePlay/platform-token session would silently resolve to\n a different, empty account, not \"safely resume as a guest.\" If\n `lastAuthType` is one of those, `autoLogin()` returns\n `{ ok: false, reason: \"client\" }` instead; the app must obtain a fresh token\n from that platform's SDK and call the matching `login*` method itself.\n- **The exact `error` strings on `reason: \"server\"` are backend-defined\n codes/messages**, not prose meant for direct display — surface them through\n your own copy/localization layer rather than showing them raw. Verified\n values from the backend for the flows above: `\"INVALID_INPUT_DATA\"`\n (missing/blank required field) and `\"INCORRECT_EMAIL_OR_PASSWORD\"` (bad\n credentials **or** unknown email — the backend intentionally doesn't\n distinguish the two, so don't tell the player \"no such account\") and\n `\"INVALID_VERIFICATION_CODE\"` (every way of getting the confirmation code\n wrong, collapsed into one) are shared\n `MessageCode` enum values also used elsewhere in the backend;\n `\"EMAIL_ALREADY_EXISTS\"` is **no longer returned by registration** — the form\n answers identically for a free and for a taken address, on purpose;\n `\"RATE_LIMIT_EXCEEDED\"` / `\"OPERATION_IN_PROGRESS\"` (per-account login lock\n — see below) are the same enum family. Google/platform-token logins instead\n return ad-hoc string literals specific to that flow, e.g. `\"BANNED_GLOBAL\"`\n (platform account is banned), `\"GOOGLE_ACCOUNT_CONFLICT\"` (email already\n linked to a _different_ Google account), `\"INVALID_GOOGLE_TOKEN\"`\n (bad/expired Google ID token) — see\n [references/data-model.md](references/data-model.md) for the full list.\n Telegram login instead surfaces plain sentences like `\"Invalid Telegram\ndata\"` / an internal `\"Telegram auth_date is stale\"` condition (initData\n older than 24h or timestamped implausibly in the future) — treat any\n non-uppercase-code string as an opaque message, not something to\n pattern-match on.\n- **Per-account login is rate-limited server-side, independent of the SDK's\n own 600 ms throttle.** Repeated `loginWithEmail` attempts for the same\n account inside a ~1-second window come back `\"RATE_LIMIT_EXCEEDED\"`; a login\n already being processed for that account comes back\n `\"OPERATION_IN_PROGRESS\"`. This is a brute-force guard, not a bug — don't\n retry-loop past it.\n- **Telegram login needs the platform adapter's init data.** `loginWithTelegram()`\n calls `platform.getTelegramInitDataRaw()` first; if that returns falsy (not\n running inside Telegram, or the adapter doesn't support it), the call fails\n client-side with `reason: \"client\"` before any network request — no point\n retrying without fixing the environment. Server-side, the init data is\n rejected if its `auth_date` is missing, more than 24 hours old, or more than\n 5 minutes in the future — a Telegram Mini App that's been idle a long time\n may need a fresh WebApp launch, not just a retry. On success it's recorded\n under its own `AuthType.Telegram` (not `Device`) precisely so `autoLogin()`\n replays `loginWithTelegram()` again rather than a device-id login.\n- **`loginWithPlatformToken` no longer exists.** The site used to hand games the\n platform auth token — a 24-hour string that also authorizes the whole\n management API. It was replaced by the one-time SSO code\n (`loginWithSsoCode`), and the endpoint has since been removed as unreachable.\n A session opened by that code is still recorded under `AuthType.iDosGames`,\n which is what `lastAuthType` reports afterward.\n- **A username you pass in is a last resort, not a guarantee.** For Google and\n platform-token logins, the backend prefers the identity provider's own\n profile name over any client-supplied username on every login (not just the\n first) — so a locally cached display name can be silently overwritten by the\n linked account's name. If no name is available from any source at\n registration time, the backend assigns a generated one\n (`\"BraveTiger482\"`-style: adjective + noun + 2–4 digit number) rather than\n leaving it blank.\n- **`forgotPassword` never reveals whether an email is registered.** Unknown\n emails and known emails both return `{ ok: true }` with no email actually\n sent for the unknown case — this is a deliberate anti-enumeration measure,\n not a bug to work around. The emailed reset code is a 6-digit number valid\n for 5 minutes; requesting again inside a 60-second cooldown silently\n no-ops (still returns `ok: true`, no second email). `resetPassword` allows\n at most 5 wrong-code attempts before the code is invalidated server-side and\n a fresh `forgotPassword` call is required.\n ⚠ **The remaining-attempt count is NOT in the response any more**, and neither\n is \"no such account\": every outcome of `resetPassword` — unknown address, no\n code requested, expired code, wrong code — answers `\"INVALID_RESET_CODE\"`, and\n an exhausted code answers `\"RESET_CODE_ATTEMPTS_EXCEEDED\"`. Reporting the\n remaining count both confirmed the account existed and told an attacker how\n much room was left. Don't parse a number out of `error`.\n- **Email credentials are only saved on success.** `loginWithEmail` and\n `confirmEmailRegistration` persist email+password to local storage (scoped by\n `titleID`) only after `fetchAndApplyClientState` succeeds — a failed login\n doesn't overwrite previously saved credentials, and a fresh install has\n nothing saved until the first successful email login. `registerWithEmail` on\n its own saves nothing: at that point the account does not exist yet.\n- **`refreshSession()` has a re-entrancy guard.** If it's already running\n (e.g. two requests 401 at nearly the same time), a second call returns `null`\n immediately instead of recursing — this exists to avoid deadlocking the\n retry, so don't assume a `null` result means the session is actually dead.\n- **`logout()` forgets the saved login method too (changed in core 0.1.3).** It\n nulls the auth context, wipes the cached user state and title-config bundle,\n clears `lastAuthType` and any saved email/password, and emits\n `auth:loggedOut` — so a later `autoLogin()` has nothing to resume. Before\n 0.1.3 storage survived a logout and the next launch silently signed the\n player back into the account they had just left. If you want a sign-out the\n player is remembered through, don't call `logout()`.\n- **`refreshSession()` and `logout()` don't return `OperationResult`.**\n `refreshSession()` resolves to `string | null` (the fresh session ticket, or\n `null` on failure/no saved method); `logout()` is synchronous `void`. Don't\n branch on `.ok` for these two.\n- **`GameLoop` is excluded from the login `ClientState`.** Board/stage state\n and its definitions are fetched separately per-stage by the GameLoop\n feature, not bundled into every login response.\n- **Guard against double-submit.** Each call fires a real request; a\n double-tapped \"Log in\" button can fire twice. Firing the same auth endpoint\n again inside the SDK's own throttle window (default 600 ms) comes back\n `reason: \"throttled\"` rather than duplicating the call, but disable the\n control while a call is in flight rather than relying on that — and note the\n server has its own, stricter per-account lock on top (see above).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — request/response wire\nshapes, the exact backend validation and error-code rules for each login/reset\npath, UserID derivation, and the default-username generator. Read it when you\nneed to reason about _why_ a specific `error` string came back, or when\nbuilding a password-reset or registration UI that needs to match the backend's\ntiming/attempt rules exactly.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Authentication data model — reference\n\nWire shapes for every login/reset request and response, the backend\nvalidation/error-code rules behind each `reason: \"server\"` failure, how a\nUserID is derived per login method, and the default-username generator. All of\nthis is transcribed from the backend source\n(`IDosGamesSDK/API/Client/v2/Authentication/*.cs`) and the TS SDK's\n`AuthenticationService.ts` / `AuthenticationModels.ts` — nothing here is\ninferred by analogy with another module.\n\n## Contents\n\n- [Request shape](#request-shape) — what each `client.auth.*` call sends\n- [Response shapes](#response-shapes) — `PlatformLoginResponse` / `SuccessResponse`\n- [UserID derivation per login method](#userid-derivation-per-login-method)\n- [Per-method validation & error codes](#per-method-validation--error-codes)\n- [Password reset flow in detail](#password-reset-flow-in-detail)\n- [Default username generation](#default-username-generation)\n- [Rate limits & locks](#rate-limits--locks)\n\n---\n\n## Request shape\n\nEvery `client.auth.*` call builds one shared shape\n(`AuthenticationRequest`, extending the SDK-wide `BaseRequest`) and only fills\nthe fields that method needs. You never construct this yourself — it's\ninternal to `AuthenticationService` — but knowing the shape explains which\nargument maps to which backend check.\n\n```ts\ninterface AuthenticationRequest {\n // BaseRequest fields used by auth:\n DeviceID?: string;\n Device?: string; // device model string\n Platform?: string; // \"iOS\" | \"Android\" | \"WebGL\" | ... (best-effort match, see below)\n Email?: string;\n Password?: string;\n ResetToken?: string;\n BuildKey?: string;\n WebAppLink?: string;\n TelegramInitData?: string;\n Username?: string; // fallback only — see Gotchas in SKILL.md\n // AuthenticationRequest-specific fields:\n PlatformAuthToken?: string;\n GoogleIDToken?: string;\n}\n```\n\n`Platform` is resolved server-side by\n`AuthenticationV2.GetPlatform(string)`: exact enum match first, then a\ncase-insensitive substring match against known `Platform` values, else\n`Platform.Unknown`. It only affects analytics/attribution tagging and the\ndevice-combined-ID string — it does not gate login.\n\n`BuildKey` and `WebAppLink` are populated automatically by the SDK\n(`ctx.settings.buildKey`, `ctx.platform.getFullURL()`) and checked by\n`IGSService.CheckTitleID` before any auth logic runs — a mismatched/inactive\ntitle fails the whole request with `\"Incorrect TitleID or Status Inactive\"`\nbefore your login method's own logic even executes.\n\n---\n\n## Response shapes\n\n### `PlatformLoginResponse` — `data` on every login/register call\n\n```ts\ninterface PlatformLoginResponse {\n PlatformUserID?: string | null; // present only for Google / platform-token logins\n PlatformAuthToken?: string | null; // present only for Google / platform-token logins\n PlatformAuthTokenExpiration?: string | null;\n TitleUserID: string; // this title's UserID — always present\n TitleClientSessionTicket: string; // Bearer value for all subsequent requests\n TitleClientSessionTicketExpiration: string;\n}\n```\n\nDevice-ID, Email, and Telegram logins never populate the `Platform*` fields —\nthere's no `PlatformUserDocument` in those flows, only a title-scoped user.\nThe SDK folds this into `AuthContext` (`client.auth.context`) and then\nimmediately calls `GetClientStateExcept` to fetch the full `ClientState`,\nwhich is what login methods actually resolve with — `PlatformLoginResponse`\nitself never reaches your code.\n\n### `SuccessResponse` — `data` on `forgotPassword` / `resetPassword`\n\n```ts\ninterface SuccessResponse {\n IsCompleted?: boolean | null;\n ServerTime?: string | null;\n}\n```\n\nBoth fields are typically absent/null in practice for these two calls — treat\n`result.ok === true` as the entire signal; don't branch on `IsCompleted`.\n\n---\n\n## UserID derivation per login method\n\nThe backend computes `UserID` deterministically so the same real-world\nidentity always maps to the same title-scoped account — this is also exactly\nwhy `autoLogin()` refuses to cross login families (see SKILL.md Gotchas).\n\n| Login method | Hash input | Function |\n| -------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ |\n| `loginWithDeviceID` | `platform + device + deviceID` | `IGSService.GenerateUserID` |\n| `loginWithTelegram` | `Platform.Telegram + null + telegramUserID` | `IGSService.GenerateUserID` (fixed platform arg) |\n| `loginWithEmail` / `registerWithEmail` | `email + titleID` | `IGSService.GenerateUserIDFromEmail` |\n| `loginWithGoogle` / `loginWithPlatformToken` | `email + titleID` (email comes from the linked `PlatformUserDocument`) | `IGSService.GenerateUserIDFromEmail` |\n\nBoth functions SHA-256 the input, base64-encode, strip `=+/`, truncate to 16\nchars, uppercase, and append `TitleID`. The practical consequence: **a device\nthat later registers with email, or logs in with Google using that email,\ngets a different UserID** than its guest session — they are two separate\naccounts unless you explicitly merge them yourself (see \"Upgrade a guest\naccount to an email account\" in SKILL.md).\n\nGoogle and platform-token logins additionally go through\n`EnsureTitleAccountLinkedAsync`, which is idempotent per\n`(PlatformUserID, titleID)`: the first login for that pair creates the\ntitle-scoped `UserDataDocument` and records a `TitleLink`; every subsequent\nlogin on any device reuses the same title account and syncs `Username` /\n`AvatarUrl` from the platform profile into it.\n\n---\n\n## Per-method validation & error codes\n\nAll codes below are the literal `error` string on `{ ok: false, reason:\n\"server\", error }` (they're `MessageCode` enum names serialized via\n`.ToString()`, except where noted as plain text).\n\n### `loginWithDeviceID(deviceID)`\n\n- Missing/blank device ID → **client-side**: the SDK doesn't call this, but if\n the platform adapter returns an empty ID, the backend would reply\n `\"INVALID_INPUT_DATA\"`.\n- Otherwise always succeeds — creates the user on first call, logs in on\n every subsequent call. There is no \"wrong credentials\" case for this method.\n\n### `loginWithEmail(email, password)`\n\n- Empty email or password → `\"INVALID_INPUT_DATA\"`.\n- No `UserDataDocument` found for the (normalized) email in this title →\n `\"INCORRECT_EMAIL_OR_PASSWORD\"`.\n- Email found but password hash doesn't match → `\"INCORRECT_EMAIL_OR_PASSWORD\"`\n (same code as \"unknown email\" — deliberate anti-enumeration; never tell the\n player which one it was).\n- Per-account lock contention (see [Rate limits](#rate-limits--locks)) →\n `\"RATE_LIMIT_EXCEEDED\"` or `\"OPERATION_IN_PROGRESS\"`.\n\n### `registerWithEmail(email, password)`\n\n- Empty email or password → `\"INVALID_INPUT_DATA\"`.\n- An account with this (normalized) email already exists in this title →\n `\"EMAIL_ALREADY_EXISTS\"`.\n- Otherwise creates the account and logs in immediately. A password is always\n hashed before storage (`DataBaseService.HashPassword`) — plaintext never\n touches the document.\n\n### `loginWithGoogle(googleIDToken)`\n\nAll codes in this subsection are ad-hoc string literals specific to this\nmethod, not `MessageCode` enum values — same rules as above (surface through\nyour own copy layer), just a different source list.\n\n- `GOOGLE_CLIENT_ID` not configured on the server → `\"GOOGLE_CLIENT_ID_NOT_CONFIGURED\"`.\n- Empty token → `\"INVALID_INPUT_DATA\"`.\n- Token fails Google's own signature/audience validation →\n `\"INVALID_GOOGLE_TOKEN\"` (or `\"GOOGLE_TOKEN_VALIDATE_FAILED:<message>\"` for\n other validation exceptions).\n- Google didn't return an email, or the email isn't verified →\n `\"GOOGLE_EMAIL_MISSING\"` / `\"GOOGLE_EMAIL_NOT_VERIFIED\"`.\n- The platform account (by email) is globally banned → `\"BANNED_GLOBAL\"`.\n- The email's platform account already has a **different** Google subject\n linked (someone else's Google account claimed this email first) →\n `\"GOOGLE_ACCOUNT_CONFLICT\"`.\n- Internal failures around linking/patching surface as\n `\"FAILED_TO_LINK_TITLE_ACCOUNT\"` or `\"PATCH_SESSION_TOKENS_FAILED\"` — these\n indicate a transient backend problem, not a player-fixable error; treat like\n `\"connection\"` in your UI even though the SDK reports `reason: \"server\"`.\n\n### `loginWithPlatformToken(authToken)`\n\n- Empty token → fails **client-side** before any request\n (`reason: \"client\"`, `\"AuthToken is null or empty.\"`) — the SDK's own guard,\n separate from the backend's `\"AuthToken is null\"` for the same condition if\n it ever reached the server.\n- Token doesn't resolve to any `PlatformUserDocument` → `\"INVALID_PLATFORM_SESSION\"`.\n- Token resolves but is expired → `\"AuthToken expired. Please relogin in iDos\nGames Platform.\"` (plain text, not an enum code).\n- Platform account is globally banned → `\"BANNED_GLOBAL\"`.\n\n### `loginWithTelegram()`\n\n- No init data available from the platform adapter → fails **client-side**\n (`reason: \"client\"`, `\"Telegram initData is not available.\"`) — the request\n never reaches the network.\n- Init data is present but the title has no Telegram bot token configured →\n `\"Invalid Telegram Bot configuration (invalid or empty TelegramBotToken)\"`.\n- HMAC signature over the init data doesn't match → `\"Invalid Telegram data\"`\n (wraps an `AuthenticationException: Invalid Telegram data hash` internally).\n- `auth_date` missing, or older than 24 hours, or more than 5 minutes in the\n future (clock-skew allowance) → `\"Invalid Telegram data\"` as well (the\n underlying exception message differs — `\"Telegram auth_date is missing\"` /\n `\"...is stale\"` — but it's surfaced through the same generic path).\n- Parsed payload has no `user` object → `\"User data inside TelegramInitData is null\"`.\n\nAll of the above are plain-text messages, not `MessageCode` enum names — don't\ntry to pattern-match them as codes; treat any non-enum string as an opaque,\ndisplayable-as-is message.\n\n---\n\n## Password reset flow in detail\n\n`forgotPassword(email)`:\n\n1. If `email` is empty → `\"Email is required\"`.\n2. Email is normalized (trim + lowercase) before lookup.\n3. If no account exists for that email in this title → returns `{ ok: true }`\n anyway, **no email sent** (anti-enumeration by design — do not treat this\n as a signal the account exists).\n4. If a reset was requested less than **60 seconds** ago\n (`LastPasswordResetRequestDate`) → returns `{ ok: true }` again, silently\n skips sending a second email.\n5. If a still-valid, unexpired reset code already exists, a **new** one is\n minted anyway (old code is overwritten) — a 6-digit numeric code\n (`GenerateVerificationCode`, cryptographically random, zero-padded to 6\n digits), valid for **5 minutes** from issuance, and `PasswordResetAttempts`\n is reset to 0.\n6. Email delivery failure → `{ ok: false, error: \"Failed to send reset email.\nPlease try again later\" }`.\n\n`resetPassword(email, resetToken, password)`:\n\n1. Empty `resetToken` → `\"Reset Code is required\"`. Empty `password` →\n `\"New Password is required\"`.\n2. No account for the (normalized) email → `\"USER_NOT_FOUND\"`.\n3. If the account has already used up **5 attempts**\n (`PasswordResetAttempts >= maxResetAttempts`), the stored token is cleared\n server-side and the call fails with `\"Too many attempts. Please request a\nnew reset token\"` — a fresh `forgotPassword` call is required to continue.\n4. Every call to `resetPassword` increments `PasswordResetAttempts` by 1\n _before_ checking the code — even a call with a syntactically-valid-looking\n but wrong code counts against the 5-attempt budget.\n5. No active token stored → `\"No active reset token. Please request a new\none\"`. Token expired (past the 5-minute window) → `\"Reset token has expired.\nPlease request a new one\"`.\n6. Wrong code (but attempts remain) → `\"Invalid reset token. N attempts\nremaining\"` where `N = maxResetAttempts - attemptsSoFar` — the SDK doesn't\n parse this number out for you; if you want a countdown UI, parse the\n trailing integer out of `result.error` yourself.\n7. On success: password is rehashed and stored, the reset token/attempts/\n cooldown fields are all cleared, and a \"your password was changed\"\n notification email is sent (fire-and-forget — its failure doesn't fail the\n call).\n\nPassword itself has no server-side complexity/character-class requirement\nbeyond length — `AuthenticationService.isValidPasswordLength` (8–100 chars) is\nthe same bound the backend's hashing step tolerates; there's no separate\n\"must contain a digit\" style rule anywhere in this flow.\n\n---\n\n## Default username generation\n\nWhen a login/register path has no usable name from any source (no\nclient-supplied `Username`, and — for Google — no profile name either), the\nbackend assigns one via `UsernameGenerator.Generate()`:\n\n```\n{Adjective}{Noun}{2-4 digit number}\n```\n\ne.g. `BraveTiger482`, `MysticFalcon17`. Built from fixed pools of 512\nadjectives and 512 nouns (CamelCase, no separators, no whitespace), with the\nnumber drawn via the SDK's cryptographic RNG (`SecureRandom`, inclusive range\n10–9999) — roughly 2.6 billion possible combinations. This only happens at\naccount-creation time, inside `RegisterNewUserDocAsync`; it never overwrites\nan existing player's username later. There is no client-facing way to\nrequest regeneration through the Authentication module — a later username\nchange goes through the User module's own rename call, not through auth.\n\n---\n\n## Rate limits & locks\n\nTwo independent layers sit in front of every auth call — both are backend\nenforcement, not something the SDK can (or should) pre-check:\n\n- **Per-IP flood guard** (all `AuthenticationV2` actions): at most 60 requests\n per IP before a ~999-second block kicks in, checked before the action even\n parses. A tripped guard returns HTTP 429 directly (surfaces to the SDK as\n `reason: \"connection\"` or a non-2xx failure, not a normal `OperationResult`\n body).\n- **Per-account lock** (`loginWithEmail`, and the Google email-conflict path):\n a Redis lock keyed by UserID/email with a ~1-second rate window and a\n 5-second lock duration. A second login for the _same account_ landing\n inside that window gets `\"RATE_LIMIT_EXCEEDED\"`; one that arrives while the\n first is still being processed gets `\"OPERATION_IN_PROGRESS\"`. This is a\n brute-force/duplicate-submit guard scoped to one account — it does not\n throttle different accounts logging in concurrently.\n\nNeither layer is configurable from the client side. If you see either code,\nback off and retry after a short delay rather than looping.\n"
8
+ "content": "# Authentication data model — reference\n\nWire shapes for every login/reset request and response, the backend\nvalidation/error-code rules behind each `reason: \"server\"` failure, how a\nUserID is derived per login method, and the default-username generator. All of\nthis is transcribed from the backend source\n(`IDosGamesSDK/API/Client/v2/Authentication/*.cs`) and the TS SDK's\n`AuthenticationService.ts` / `AuthenticationModels.ts` — nothing here is\ninferred by analogy with another module.\n\n## Contents\n\n- [Request shape](#request-shape) — what each `client.auth.*` call sends\n- [Response shapes](#response-shapes) — `PlatformLoginResponse` / `SuccessResponse`\n- [UserID derivation per login method](#userid-derivation-per-login-method)\n- [Per-method validation & error codes](#per-method-validation--error-codes)\n- [Password reset flow in detail](#password-reset-flow-in-detail)\n- [Default username generation](#default-username-generation)\n- [Rate limits & locks](#rate-limits--locks)\n\n---\n\n## Request shape\n\nEvery `client.auth.*` call builds one shared shape\n(`AuthenticationRequest`, extending the SDK-wide `BaseRequest`) and only fills\nthe fields that method needs. You never construct this yourself — it's\ninternal to `AuthenticationService` — but knowing the shape explains which\nargument maps to which backend check.\n\n```ts\ninterface AuthenticationRequest {\n // BaseRequest fields used by auth:\n DeviceID?: string;\n Device?: string; // device model string\n Platform?: string; // \"iOS\" | \"Android\" | \"WebGL\" | ... (best-effort match, see below)\n Email?: string;\n Password?: string;\n ResetToken?: string;\n BuildKey?: string;\n WebAppLink?: string;\n TelegramInitData?: string;\n Username?: string; // fallback only — see Gotchas in SKILL.md\n // AuthenticationRequest-specific fields:\n PlatformAuthToken?: string;\n GoogleIDToken?: string;\n}\n```\n\n`Platform` is resolved server-side by\n`AuthenticationV2.GetPlatform(string)`: exact enum match first, then a\ncase-insensitive substring match against known `Platform` values, else\n`Platform.Unknown`. It only affects analytics/attribution tagging and the\ndevice-combined-ID string — it does not gate login.\n\n`BuildKey` and `WebAppLink` are populated automatically by the SDK\n(`ctx.settings.buildKey`, `ctx.platform.getFullURL()`) and checked by\n`IGSService.CheckTitleID` before any auth logic runs — a mismatched/inactive\ntitle fails the whole request with `\"Incorrect TitleID or Status Inactive\"`\nbefore your login method's own logic even executes.\n\n---\n\n## Response shapes\n\n### `PlatformLoginResponse` — `data` on every login/register call\n\n```ts\ninterface PlatformLoginResponse {\n PlatformUserID?: string | null; // present only for Google / platform-token logins\n PlatformAuthToken?: string | null; // present only for Google / platform-token logins\n PlatformAuthTokenExpiration?: string | null;\n TitleUserID: string; // this title's UserID — always present\n TitleClientSessionTicket: string; // Bearer value for all subsequent requests\n TitleClientSessionTicketExpiration: string;\n}\n```\n\nDevice-ID, Email, and Telegram logins never populate the `Platform*` fields —\nthere's no `PlatformUserDocument` in those flows, only a title-scoped user.\nThe SDK folds this into `AuthContext` (`client.auth.context`) and then\nimmediately calls `GetClientStateExcept` to fetch the full `ClientState`,\nwhich is what login methods actually resolve with — `PlatformLoginResponse`\nitself never reaches your code.\n\n### `SuccessResponse` — `data` on `forgotPassword` / `resetPassword`\n\n```ts\ninterface SuccessResponse {\n IsCompleted?: boolean | null;\n ServerTime?: string | null;\n}\n```\n\nBoth fields are typically absent/null in practice for these two calls — treat\n`result.ok === true` as the entire signal; don't branch on `IsCompleted`.\n\n---\n\n## UserID derivation per login method\n\nThe backend computes `UserID` deterministically so the same real-world\nidentity always maps to the same title-scoped account — this is also exactly\nwhy `autoLogin()` refuses to cross login families (see SKILL.md Gotchas).\n\n| Login method | Hash input | Function |\n| -------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ |\n| `loginWithDeviceID` | `platform + device + deviceID` | `IGSService.GenerateUserID` |\n| `loginWithTelegram` | `Platform.Telegram + null + telegramUserID` | `IGSService.GenerateUserID` (fixed platform arg) |\n| `loginWithEmail` / `registerWithEmail` | `email + titleID` | `IGSService.GenerateUserIDFromEmail` |\n| `loginWithGoogle` / `loginWithPlatformToken` | `email + titleID` (email comes from the linked `PlatformUserDocument`) | `IGSService.GenerateUserIDFromEmail` |\n\nBoth functions SHA-256 the input, base64-encode, strip `=+/`, truncate to 16\nchars, uppercase, and append `TitleID`. The practical consequence: **a device\nthat later registers with email, or logs in with Google using that email,\ngets a different UserID** than its guest session — they are two separate\naccounts unless you explicitly merge them yourself (see \"Upgrade a guest\naccount to an email account\" in SKILL.md).\n\nGoogle and platform-token logins additionally go through\n`EnsureTitleAccountLinkedAsync`, which is idempotent per\n`(PlatformUserID, titleID)`: the first login for that pair creates the\ntitle-scoped `UserDataDocument` and records a `TitleLink`; every subsequent\nlogin on any device reuses the same title account and syncs `Username` /\n`AvatarUrl` from the platform profile into it.\n\n---\n\n## Per-method validation & error codes\n\nAll codes below are the literal `error` string on `{ ok: false, reason:\n\"server\", error }` (they're `MessageCode` enum names serialized via\n`.ToString()`, except where noted as plain text).\n\n### `loginWithDeviceID(deviceID)`\n\n- Missing/blank device ID → **client-side**: the SDK doesn't call this, but if\n the platform adapter returns an empty ID, the backend would reply\n `\"INVALID_INPUT_DATA\"`.\n- Otherwise always succeeds — creates the user on first call, logs in on\n every subsequent call. There is no \"wrong credentials\" case for this method.\n\n### `loginWithEmail(email, password)`\n\n- Empty email or password → `\"INVALID_INPUT_DATA\"`.\n- No `UserDataDocument` found for the (normalized) email in this title →\n `\"INCORRECT_EMAIL_OR_PASSWORD\"`.\n- Email found but password hash doesn't match → `\"INCORRECT_EMAIL_OR_PASSWORD\"`\n (same code as \"unknown email\" — deliberate anti-enumeration; never tell the\n player which one it was).\n- Per-account lock contention (see [Rate limits](#rate-limits--locks)) →\n `\"RATE_LIMIT_EXCEEDED\"` or `\"OPERATION_IN_PROGRESS\"`.\n\n### `registerWithEmail(email, password)`\n\n⚠ This call **starts** a registration and never finishes one. No player, platform identity,\ncredential or session exists until `confirmEmailRegistration` succeeds; the attempt waits in its own\nshort-lived collection. Confirmation is platform behaviour — a title cannot switch it off.\n\n- Empty email or password → `\"INVALID_INPUT_DATA\"`.\n- The address does not parse as an address → `\"INCORRECT_EMAIL\"`.\n- Password outside 8–100 characters → `\"PASSWORD_LENGTH_INVALID\"`. The server checks this itself;\n it used to check nothing, and the client-side rule was the only one.\n- **A taken address answers exactly like a free one** — success, and no session either way. There\n is no `EMAIL_ALREADY_EXISTS` here any more: telling the two apart made the form an oracle for\n \"does this person have an account\". The owner of the taken address receives a \"you already have\n an account\" e-mail, without a code.\n- No usable mail sender on the server — neither the title's own SMTP nor the platform one →\n `\"EMAIL_SENDER_NOT_CONFIGURED\"`. **The only failure of this form that reports anything**, and it\n is not an enumeration leak: the answer describes the server's configuration, so it is identical\n for a free address, a taken one and a nonsense one. It exists because confirmation cannot be\n switched off — without a sender, registration by e-mail does not work at all, and a silent\n success would hide that behind \"we sent you a code\". Nothing is written when it happens.\n ⚠ Do not confuse it with a letter that failed to leave (see below): that one still answers\n success, because *that* outcome can depend on the recipient.\n- The response carries only `CodeTtlMinutes` and `ResendCooldownSeconds` — what the code screen\n needs to show. Silence about the address has to be covered by a letter, which is exactly why the\n confirmation step cannot be turned off.\n\nA password is always hashed before storage (`DataBaseService.HashPassword`) — plaintext never\ntouches any document, including the pending-registration one.\n\n### `confirmEmailRegistration(email, code)`\n\n- Empty email or code → `\"INVALID_INPUT_DATA\"`.\n- **Every** other failure → `\"INVALID_VERIFICATION_CODE\"`: no such registration, expired, wrong\n code, or lost a race with another confirmation. Do not branch on it — the reason is the same as\n above, and the same as in the password-reset form.\n- Attempts exhausted → `\"VERIFICATION_CODE_ATTEMPTS_EXCEEDED\"`, and the attempt is discarded\n entirely: a new registration is needed. Keeping it would have made brute force unlimited via\n resends.\n- On success the player is created, the address is marked proven, and the e-mail credential\n becomes `Strong` — which is what unlocks joining accounts by a matching address.\n\n### `resendVerificationCode(email)`\n\n- Answers success — whether a registration was started, whether the cooldown passed, whether the\n letter actually left. Anything else would report on the address.\n- The single exception is `\"EMAIL_SENDER_NOT_CONFIGURED\"`, exactly as in `registerWithEmail`:\n otherwise a player whose letter never arrives would press \"send again\" forever and be told it\n worked every time.\n- Sends a **new** code; the previous one stops working. Only the hash of a code is stored, so\n re-sending the old one is impossible by construction.\n\n### `loginWithGoogle(googleIDToken)`\n\nAll codes in this subsection are ad-hoc string literals specific to this\nmethod, not `MessageCode` enum values — same rules as above (surface through\nyour own copy layer), just a different source list.\n\n- `GOOGLE_CLIENT_ID` not configured on the server → `\"GOOGLE_CLIENT_ID_NOT_CONFIGURED\"`.\n- Empty token → `\"INVALID_INPUT_DATA\"`.\n- Token fails Google's own signature/audience validation →\n `\"INVALID_GOOGLE_TOKEN\"` (or `\"GOOGLE_TOKEN_VALIDATE_FAILED:<message>\"` for\n other validation exceptions).\n- Google didn't return an email, or the email isn't verified →\n `\"GOOGLE_EMAIL_MISSING\"` / `\"GOOGLE_EMAIL_NOT_VERIFIED\"`.\n- The platform account (by email) is globally banned → `\"BANNED_GLOBAL\"`.\n- The email's platform account already has a **different** Google subject\n linked (someone else's Google account claimed this email first) →\n `\"GOOGLE_ACCOUNT_CONFLICT\"`.\n- Internal failures around linking/patching surface as\n `\"FAILED_TO_LINK_TITLE_ACCOUNT\"` or `\"PATCH_SESSION_TOKENS_FAILED\"` — these\n indicate a transient backend problem, not a player-fixable error; treat like\n `\"connection\"` in your UI even though the SDK reports `reason: \"server\"`.\n\n### `loginWithPlatformToken(authToken)` — removed\n\nThe site used to hand games the platform auth token: a 24-hour string that also authorizes the\nwhole management API (billing, titles, AI Coder). Games are publisher-authored code, so that was\nreplaced by the one-time SSO code (`loginWithSsoCode`) — 60 seconds, one exchange, one title.\n\nThe endpoint itself has since been deleted as unreachable: it only accepted a session of the\n\"game\" audience, and the management backend stopped issuing those. Use `loginWithSsoCode`.\n\n### `loginWithSsoCode(code)`\n\n- Empty code → fails **client-side** before any request\n (`reason: \"client\"`) — the SDK's own guard.\n- Unknown, expired, already exchanged, or issued for another title →\n `\"INVALID_OR_USED_SSO_CODE\"`. One code for all four: the code is burnt and matched inside a\n single atomic operation, so the reasons are not distinguishable after the fact.\n- Issued with a PKCE challenge but exchanged without a matching `code_verifier` →\n `\"INVALID_CODE_VERIFIER\"`. ⚠ The code is burnt anyway — a mismatch means it is being presented\n by someone other than whoever asked for it.\n- Platform account is globally banned → `\"BANNED_GLOBAL\"`.\n\n### `loginWithTelegram()`\n\n- No init data available from the platform adapter → fails **client-side**\n (`reason: \"client\"`, `\"Telegram initData is not available.\"`) — the request\n never reaches the network.\n- Init data is present but the title has no Telegram bot token configured →\n `\"Invalid Telegram Bot configuration (invalid or empty TelegramBotToken)\"`.\n- HMAC signature over the init data doesn't match → `\"Invalid Telegram data\"`\n (wraps an `AuthenticationException: Invalid Telegram data hash` internally).\n- `auth_date` missing, or older than 24 hours, or more than 5 minutes in the\n future (clock-skew allowance) → `\"Invalid Telegram data\"` as well (the\n underlying exception message differs — `\"Telegram auth_date is missing\"` /\n `\"...is stale\"` — but it's surfaced through the same generic path).\n- Parsed payload has no `user` object → `\"User data inside TelegramInitData is null\"`.\n\nAll of the above are plain-text messages, not `MessageCode` enum names — don't\ntry to pattern-match them as codes; treat any non-enum string as an opaque,\ndisplayable-as-is message.\n\n---\n\n## Password reset flow in detail\n\n`forgotPassword(email)`:\n\n1. If `email` is empty → `\"Email is required\"`.\n2. Email is normalized (trim + lowercase) before lookup.\n3. If no account exists for that email in this title → returns `{ ok: true }`\n anyway, **no email sent** (anti-enumeration by design — do not treat this\n as a signal the account exists).\n4. If a reset was requested less than **60 seconds** ago\n (`LastPasswordResetRequestDate`) → returns `{ ok: true }` again, silently\n skips sending a second email.\n5. If a still-valid, unexpired reset code already exists, a **new** one is\n minted anyway (old code is overwritten) — a 6-digit numeric code\n (`GenerateVerificationCode`, cryptographically random, zero-padded to 6\n digits), valid for **5 minutes** from issuance, and `PasswordResetAttempts`\n is reset to 0.\n6. Email delivery failure → `{ ok: true }`. A refusal here would have appeared **only** for an\n address that actually has an account (a missing one returns success at step 3), so the whole\n anti-enumeration property of the form collapsed precisely when mail broke. The failure goes to\n the server log and to the dashboard's test-send button instead.\n7. No usable sender at all (neither the title's SMTP nor the platform's) →\n `{ ok: false, error: \"EMAIL_SENDER_NOT_CONFIGURED\" }`. Checked **first**, before the account\n lookup and before any write: the answer does not depend on the address, and issuing a code\n nobody can deliver would burn the still-valid code from the previous letter.\n\n`resetPassword(email, resetToken, password)`:\n\n1. Empty `resetToken` → `\"Reset Code is required\"`. Empty `password` →\n `\"New Password is required\"`.\n2. No account for the (normalized) email → `\"USER_NOT_FOUND\"`.\n3. If the account has already used up **5 attempts**\n (`PasswordResetAttempts >= maxResetAttempts`), the stored token is cleared\n server-side and the call fails with `\"Too many attempts. Please request a\nnew reset token\"` — a fresh `forgotPassword` call is required to continue.\n4. Every call to `resetPassword` increments `PasswordResetAttempts` by 1\n _before_ checking the code — even a call with a syntactically-valid-looking\n but wrong code counts against the 5-attempt budget.\n5. No active token stored → `\"No active reset token. Please request a new\none\"`. Token expired (past the 5-minute window) → `\"Reset token has expired.\nPlease request a new one\"`.\n6. Wrong code (but attempts remain) → `\"Invalid reset token. N attempts\nremaining\"` where `N = maxResetAttempts - attemptsSoFar` — the SDK doesn't\n parse this number out for you; if you want a countdown UI, parse the\n trailing integer out of `result.error` yourself.\n7. On success: password is rehashed and stored, the reset token/attempts/\n cooldown fields are all cleared, and a \"your password was changed\"\n notification email is sent (fire-and-forget — its failure doesn't fail the\n call).\n\nPassword itself has no server-side complexity/character-class requirement\nbeyond length — `AuthenticationService.isValidPasswordLength` (8–100 chars) is\nthe same bound the backend's hashing step tolerates; there's no separate\n\"must contain a digit\" style rule anywhere in this flow.\n\n---\n\n## Default username generation\n\nWhen a login/register path has no usable name from any source (no\nclient-supplied `Username`, and — for Google — no profile name either), the\nbackend assigns one via `UsernameGenerator.Generate()`:\n\n```\n{Adjective}{Noun}{2-4 digit number}\n```\n\ne.g. `BraveTiger482`, `MysticFalcon17`. Built from fixed pools of 512\nadjectives and 512 nouns (CamelCase, no separators, no whitespace), with the\nnumber drawn via the SDK's cryptographic RNG (`SecureRandom`, inclusive range\n10–9999) — roughly 2.6 billion possible combinations. This only happens at\naccount-creation time, inside `RegisterNewUserDocAsync`; it never overwrites\nan existing player's username later. There is no client-facing way to\nrequest regeneration through the Authentication module — a later username\nchange goes through the User module's own rename call, not through auth.\n\n---\n\n## Rate limits & locks\n\nTwo independent layers sit in front of every auth call — both are backend\nenforcement, not something the SDK can (or should) pre-check:\n\n- **Per-IP flood guard** (all `AuthenticationV2` actions): at most 60 requests\n per IP before a ~999-second block kicks in, checked before the action even\n parses. A tripped guard returns HTTP 429 directly (surfaces to the SDK as\n `reason: \"connection\"` or a non-2xx failure, not a normal `OperationResult`\n body).\n- **Per-account lock** (`loginWithEmail`, and the Google email-conflict path):\n a Redis lock keyed by UserID/email with a ~1-second rate window and a\n 5-second lock duration. A second login for the _same account_ landing\n inside that window gets `\"RATE_LIMIT_EXCEEDED\"`; one that arrives while the\n first is still being processed gets `\"OPERATION_IN_PROGRESS\"`. This is a\n brute-force/duplicate-submit guard scoped to one account — it does not\n throttle different accounts logging in concurrently.\n\nNeither layer is configurable from the client side. If you see either code,\nback off and retry after a short delay rather than looping.\n"
9
9
  }
10
10
  ]
11
11
  }