@idosgames/mcp 0.1.7 → 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 +5 -5
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +16 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/authentication.json +3 -3
- package/registry/skills/collection-system.json +1 -1
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/game-loop-system.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-title-bootstrap.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/social-system.json +1 -1
- package/registry/skills/user-profile.json +2 -2
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,
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
|
|
127
|
-
'"https://site.idosgames.com/api/v2/mcp"
|
|
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.
|
|
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",
|
package/registry/host.json
CHANGED
|
@@ -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.
|
|
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",
|
package/registry/index.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedFromCommit": "
|
|
2
|
+
"generatedFromCommit": "8d95686552c20e3df4d8c650bd44d52e0bfcba10",
|
|
3
3
|
"runtimePackages": {
|
|
4
|
-
"@idosgames/core": "0.
|
|
5
|
-
"@idosgames/wallet": "0.2.
|
|
6
|
-
"@idosgames/module-sdk": "0.1.
|
|
7
|
-
"@idosgames/react": "0.2.
|
|
8
|
-
"@idosgames/app-shell": "0.1.
|
|
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.
|
|
50
|
-
"@idosgames/module-sdk": "0.1.
|
|
51
|
-
"@idosgames/react": "0.2.
|
|
52
|
-
"@idosgames/wallet": "0.2.
|
|
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.
|
|
96
|
-
"@idosgames/module-sdk": "0.1.
|
|
97
|
-
"@idosgames/react": "0.2.
|
|
98
|
-
"@idosgames/wallet": "0.2.
|
|
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.
|
|
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
|
|
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",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.2.
|
|
43
|
-
"@idosgames/wallet": "0.2.
|
|
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.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.2.
|
|
43
|
-
"@idosgames/wallet": "0.2.
|
|
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",
|
|
@@ -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
|
|
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
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Collection data model — reference\n\nFull shape of the config (`CollectionDefinitions`), player state\n(`UserCollectionState`), the pack/chest reward mechanics, and the trade-offer\nlifecycle. Config types are **strictly typed in the SDK** — `CollectionDefinitions`\nand every nested block are exported from `@idosgames/core`. Every schema keeps\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CollectionDefinitions](#config-collectiondefinitions)\n- [CollectionDefinition / Sets / Collectibles](#collectiondefinition--sets--collectibles)\n- [PackTypes and CollectionChests (reward slots + pity)](#packtypes-and-collectionchests)\n- [Duplicate conversion](#duplicate-conversion)\n- [SpecialTradeEvents](#specialtradeevents)\n- [Player state: UserCollectionState](#player-state-usercollectionstate)\n- [Season-linked wipe](#season-linked-wipe)\n- [Trade offers](#trade-offers)\n- [Responses](#responses)\n\n---\n\n## Config: CollectionDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CollectionDefinitions>(\"Collection\")`.\n\n```ts\ninterface CollectionDefinitions {\n Collections?: Record<string, CollectionDefinition> | null; // key = CollectionID\n PackTypes?: Record<string, CollectionPackTypeDefinition> | null; // key = PackTypeID\n CollectionChests?: CollectionChestDefinition[] | null;\n DuplicateConversions?: DuplicateCollectionCurrencyConversion[] | null;\n DailyTradeLimit?: number | null;\n CollectibleJokerCatalogID?: string | null;\n CollectibleJokerItemID?: string | null;\n SpecialTradeEvents?: SpecialTradeEventDefinition[] | null;\n}\n```\n\n`DailyTradeLimit` bounds `sendTradeOffer` calls per calendar day (tracked by\n`UserCollectionState.DailyTradesSent` / `DailyTradesResetDate`, reset at UTC\nmidnight); backend default is **5/day** if the title doesn't set it.\n`CollectibleJokerItemID` (optionally scoped by `CollectibleJokerCatalogID`) is\nthe item burned by `useCollectibleJoker` — grant this item to players through\nthe Item/Store/Lootbox modules; the Collection module only consumes it. It's a\nnormal `InventoryV2.Items` item and does not burn on a season wipe, so players\ncan bank Jokers across seasons.\n\n---\n\n## CollectionDefinition / Sets / Collectibles\n\nThree-level hierarchy: Collection → Set → Collectible.\n\n```ts\ninterface CollectionDefinition {\n CollectionID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n SeasonChainID?: string; // links this collection to a season chain, if any\n Sets?: CollectionSetDefinition[];\n GrandPrize?: ResourceGrant; // claimed once via claimGrandPrize()\n}\n\ninterface CollectionSetDefinition {\n SetID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n SortOrder?: number;\n Collectibles?: CollectibleDefinition[];\n SetCompletionReward?: ResourceGrant; // claimed once via claimSetReward()\n}\n\ninterface CollectibleDefinition {\n CollectibleID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Rarity?: number;\n HasSpecialVersion?: boolean; // a rarer \"Special\" variant exists for this id\n SortOrder?: number;\n}\n```\n\nA Collection is \"completed\" (`IsCollectionCompleted`) once every Set inside it\nis completed; a Set is completed once every listed Collectible has been\nobtained at least once (`OwnedCollectibles[id] >= 1`). Owning duplicates past\n1 does not grant anything further directly — see\n[Duplicate conversion](#duplicate-conversion).\n\n---\n\n## PackTypes and CollectionChests\n\nBoth are openable reward containers priced differently: Packs cost the shared\n`ResourceConsume` type (currency/items/event tokens); Chests are priced purely\nin `CollectionCurrencyCost` (the module's own soft currency, earned from\nduplicates).\n\n```ts\ninterface CollectionPackTypeDefinition {\n PackTypeID?: string;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; the selected one is charged by openPack(), required non-empty or the open is rejected\n BonusRewardSlots?: LootboxRewardSlot[]; // extra non-collectible rewards\n PityRules?: LootboxPityRule[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectibleCount?: number; // how many Collectibles this pack grants (backend default 3)\n GuaranteedMinRarity?: number; // backend default 1\n GuaranteeMaxRarity?: boolean; // backend default false\n RarityWeights?: Record<string, number>; // rarity id (as string \"1\"..\"5\") -> drop weight\n ColorTier?: number; // 1=Green,2=Blue,3=Orange,4=Purple; backend default 1\n}\n\ninterface CollectionChestDefinition {\n CollectionChestID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectionCurrencyCost?: number;\n MinCollectibleCount?: number; // backend default 1\n MaxCollectibleCount?: number; // backend default 2\n GuaranteedMinRarity?: number; // backend default 2\n BonusRewardSlots?: LootboxRewardSlot[];\n PityRules?: LootboxPityRule[];\n Tier?: number; // 1=Bronze,2=Silver,3=Gold; backend default 1\n}\n```\n\n`BonusRewardSlots` / `PityRules` reuse the shared reward-slot primitives from\n`_shared/RewardSlotModels.ts` (the same ones the Lootbox module uses — see\n[lootbox-system](../../lootbox-system/SKILL.md) if you need the full\nslot/pool/pity mechanics):\n\n```ts\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only; no Consume side\n Weight?: number;\n AmountRange?: { Min?: number; Max?: number };\n}\ninterface LootboxRewardSlot {\n SlotID?: string;\n MinRolls?: number; // independent rolls to make over Pool\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxPityRule {\n RuleID?: string;\n Threshold?: number; // every Nth open without a qualifying pull, force one\n Pool?: LootboxRewardRoll[];\n}\n```\n\n**Pack roll (`OpenPack`, per Collectible slot, `Collection.cs`\n`RollCollectiblesForPack`):** slot 0 gets the pack's guarantee — if\n`GuaranteeMaxRarity` is true it forces rarity 5, otherwise it weighted-picks\nfrom `RarityWeights` with a floor of `GuaranteedMinRarity`; every other slot\nweighted-picks with a floor of rarity 1. The weighted pick\n(`PickRarityWeighted`) filters `RarityWeights` entries to `rarity >= floor`,\nsums their weights, and rolls uniformly in `[0, total)` via `SecureRandom`; if\nno candidate matches the exact rarity it widens to `>= targetRarity`, then\nfalls back to the full Collectible pool. A picked Collectible that already has\n`HasSpecialVersion: true` additionally has a **flat 5% chance** to be granted\nas its Special version instead of the normal one (`SecureRandom.Next(0,100) <\n5`) — this 5% is hardcoded, not title-configurable.\n\n**Chest roll (`OpenCollectionChest`, `RollCollectiblesForCollectionChest`):**\npicks a random Collectible count uniformly in\n`[MinCollectibleCount, MaxCollectibleCount]`, then for each pick filters the\npool to `Rarity >= GuaranteedMinRarity` (falling back to the full pool if that\nfilter is empty) and picks uniformly at random. Chests never roll Special\nversions.\n\n**Duplicate detection happens per-roll, in-order, within the same open**: the\n\"already owned\" check adds in any Collectibles already granted earlier in the\n_same_ pack/chest before checking the next slot, so pulling the same\nCollectible twice in one 5-slot pack correctly flags the second as a\nduplicate even though neither has hit the database yet.\n\nPity progress is tracked per rule in\n`UserCollectionState.PityCounters: Record<string, UserLootboxPityCounter>`,\nkeyed by **`\"{PackTypeID or CollectionChestID}:{RuleID}\"`** (literal colon\njoin; `CollectionPityHelpers.CounterKey`) — not by `RuleID` alone, so the same\n`RuleID` reused across two pack types tracks independently. The counter type\nis shared with the Lootbox module. Math per open (count is always 1 for\nCollection, unlike Lootbox's multi-open): `totalSteps = counter + 1`,\n`triggers = totalSteps / Threshold` (0 or 1), `newCounter = totalSteps %\nThreshold` — i.e. classic hard-pity, resets to 0 exactly on the open that\nhits the threshold. `OpenPackResponse` / `OpenCollectionChestResponse` both\ncarry `TriggeredPity: unknown[]` — the response signals _that_ pity fired\n(with `RuleID`, always `BoxIndex: 0` for Collection) but doesn't strictly\ntype the payload shape; treat it as informational (e.g. a \"pity!\" toast)\nrather than something to branch business logic on.\n\n---\n\n## Duplicate conversion\n\n```ts\ninterface DuplicateCollectionCurrencyConversion {\n Rarity?: number;\n CollectionCurrencyGranted?: number;\n}\n```\n\nWhen a pack/chest pull is a Collectible the player already owns, instead of\nstacking uselessly it auto-converts into `CollectionCurrencyGranted` (looked\nup by the pulled Collectible's `Rarity` in this list) — that's the\n`CollectionCurrencyEarned` you see on `OpenPackResponse` /\n`OpenCollectionChestResponse`, and it's what funds `openCollectionChest`. This\nis why chests exist: a way to spend \"wasted\" duplicate pulls on guaranteed\nprogress instead.\n\n**Fallback when no rule matches the rarity:** `GetCollectionCurrencyForDuplicate`\nfalls back to `rarity` itself (i.e. a rarity-3 duplicate grants 3 Collection\nCurrency) if `DuplicateConversions` has no entry for that rarity — so an\nincomplete conversion table doesn't silently grant 0, but also won't match\nwhatever curve you intended. Configure every rarity 1-5 explicitly rather than\nrelying on the fallback.\n\nA duplicate normally caps ownership at effectively 1 (the doc comment on\n`UserCollectionState.OwnedCollectibles` calls `>= 2` a rare/transient state —\nconversion is meant to be immediate) but the code path that increments it is\nplain `Dictionary` arithmetic in memory before the Mongo patch, so treat\n`OwnedCollectibles[id]` as \"0, 1, or rarely-briefly more,\" not a strict\nboolean.\n\n---\n\n## SpecialTradeEvents\n\n```ts\ninterface SpecialTradeEventDefinition {\n SpecialTradeEventID?: string;\n StartUtc?: string;\n EndUtc?: string;\n AllowedSpecialCollectibleIDs?: string[];\n SpecialTradeEventDailyTradeLimit?: number;\n}\n```\n\nSpecial-version Collectibles (`HasSpecialVersion: true` on the base\nCollectible, traded with `collectibleIsSpecial: true`) can only move via\n`sendTradeOffer` while an active event's window covers `now` **and** lists\nthat Collectible in `AllowedSpecialCollectibleIDs`. Outside any such window,\noffering a Special is rejected server-side. The event also carries its own\ndaily limit distinct from the title-wide `DailyTradeLimit`.\n\n---\n\n## Player state: UserCollectionState\n\nReturned by `getUserState()`; cached at `client.data.user.state?.Collection`.\n**Loosely typed** (`z.object({}).passthrough()` cast to the interface) —\nunlike the config side, this is not field-validated, so treat it as\nbest-effort and read defensively.\n\n```ts\ninterface UserCollectionState {\n CollectionID?: string;\n SeasonVersion?: number;\n CollectionCurrencyBalance?: number;\n TotalCollectionCurrencyEarned?: number;\n OwnedCollectibles?: Record<string, number>; // CollectibleID -> count owned\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[]; // SetIDs already claimed\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n DailyTradesSent?: number;\n DailyTradesResetDate?: string;\n PendingTradeOfferIDs?: string[];\n PityCounters?: Record<string, UserLootboxPityCounter>; // key = \"{PackTypeID|CollectionChestID}:{RuleID}\"\n}\n```\n\n`SeasonVersion` defaults to `0` when the collection isn't season-linked.\n`PityCounters` (like `OwnedCollectibles`/`OwnedSpecialCollectibles`) is a plain\ndictionary that only gains a key the first time that pool triggers — treat a\nmissing key as counter `0`, not an error.\n\n---\n\n## Season-linked wipe\n\nA `CollectionDefinition` may set `SeasonChainID` to bind itself to a season\nchain (`Season` module). Every Collection action re-derives the \"current\"\n`(activeCollectionID, SeasonVersion)` pair on each call\n(`EnsureCollectionWipedIfNeededAsync` in `Collection.cs`):\n\n- If any season chain has a `LinkedCollectionID` whose window is currently\n active (not paused), that collection is the active one, and\n `SeasonVersion = CycleIndex * 1000 + SeasonOrder` of that window.\n- Otherwise, if the title has no season-linked collection, the **first**\n collection in config-declaration order (`Collections.Keys.First()`) is used\n with `SeasonVersion = 0`.\n\nIf the player's stored `UserCollectionState.CollectionID` /\n`SeasonVersion` doesn't match, the **entire** Collection state is wiped and\nreplaced with a fresh zeroed one (new `CollectionID`, `SeasonVersion`, empty\n`OwnedCollectibles`/`OwnedSpecialCollectibles`/`ClaimedSetRewards`/\n`PendingTradeOfferIDs`, zeroed currency, `IsCollectionCompleted`/\n`GrandPrizeClaimed` reset to `false`) — this happens **lazily**, on the very\nnext Collection call the player makes after the season rolls over, not on a\nschedule. There is no dedicated wipe event; the wiped state is simply what\nthe next `getUserState()` (or any other Collection call) returns. Design\naround this: don't assume a cached `Collection` state survives across a\nsession gap without a fresh fetch, and don't build UI that depends on\n`OwnedCollectibles` persisting across a season boundary for a season-linked\ncollection.\n\n---\n\n## Trade offers\n\n```ts\ninterface CollectionTradeOfferDocument {\n OfferID: string;\n TitleID?: string;\n CollectionID?: string;\n SenderUserID?: string;\n SenderPublicData?: UserPublicDataModel; // sender's public profile snapshot\n OfferedCollectibleID?: string;\n OfferedCollectibleIsSpecial?: boolean;\n ReceiverUserID?: string;\n RequestedCollectibleID?: string; // absent = open/gift offer, no ask-back\n RequestedCollectibleIsSpecial?: boolean;\n Status?: \"Pending\" | \"Accepted\" | \"Declined\" | \"Cancelled\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RespondedAtUtc?: string;\n DeclineReason?: string;\n IsSpecialTradeEvent?: boolean;\n SpecialTradeEventID?: string;\n}\n```\n\n**Preconditions checked by `sendTradeOffer` (`Collection.cs` `SendTradeOffer`),\nin order:** `ReceiverUserID` can't equal your own `UserID` (\"Cannot trade with\nyourself\"); the receiver must be in your **Social.Accepted friends list**\n(\"Receiver must be in your friends list\" — see\n[social-system](../../social-system/SKILL.md)); if `CollectibleIsSpecial` a\nmatching active `SpecialTradeEventDefinition` must exist (\"Special\nCollectibles can only be traded during an active SpecialTradeEvent\"); the\neffective daily limit (event-specific limit if trading a Special during its\nevent window, else the title's `DailyTradeLimit`, lazily reset at UTC\nmidnight) must not already be hit (\"Daily trade limit reached (N/day)\"); you\nmust hold enough of the offered Collectible — **`>= 2`** for a normal\nCollectible (you keep one, offer the spare) or **`>= 1`** for a Special (the\nwhole thing moves, no spare kept back); and the receiver's\n`PendingTradeOfferIDs` must have fewer than **10** entries\n(`MaxPendingIncomingOffers`) — \"Receiver has too many pending trade offers.\"\nThe same `>= 2` (normal) / `>= 1` (Special) ownership check re-runs against\nthe **receiver's** balance for `RequestedCollectibleID` at `acceptTradeOffer`\ntime, since their holdings may have changed since the offer was sent.\n\nLifecycle: `sendTradeOffer` creates a document with `Status: \"Pending\"`,\n`CreatedAtUtc: now`, and `ExpiresAtUtc: now + 168h` (7 days —\n`TradeOfferExpirationHours` in `Collection.cs`, not title-configurable). The\nreceiver calls `getIncomingTradeOffers` to see it, then either\n`acceptTradeOffer` (→ `Status: \"Accepted\"`, both Collectibles swap owners; also\nrejected if `ExpiresAtUtc <= now`, \"Offer has expired\") or `declineTradeOffer`\n(→ `Status: \"Declined\"`). The sender can `cancelTradeOffer` any offer still\n`\"Pending\"` (→ `Status: \"Cancelled\"`).\n\n**`\"Expired\"` is a declared `Status` value the backend never actually\nwrites** — there is no sweep job that flips stale offers to `Expired`.\n`getIncomingTradeOffers` filters server-side to `Status == \"Pending\" &&\nExpiresAtUtc > now`, so an expired incoming offer just silently drops out of\nthat list (it doesn't surface with a distinguishable status). `getMyTradeOffers`\n(outgoing) has **no such filter** — it returns everything you've ever sent for\nthat collection (newest 20), so a lapsed offer you sent still reads\n`Status: \"Pending\"` with an `ExpiresAtUtc` in the past; compare `ExpiresAtUtc`\nagainst the current time yourself if you need to grey it out in a \"my offers\"\nlist. None of the four trade actions mutate `client.data.user` directly (no\n`Resources`, no `Collection` cache patch) — re-fetch `getUserState()` / the\noffer lists to observe the effect.\n\n---\n\n## Responses\n\n```ts\ninterface GrantedCollectible {\n CollectibleID: string;\n Rarity?: number;\n IsSpecial?: boolean;\n IsDuplicate?: boolean;\n CollectionCurrencyConverted?: number; // set when IsDuplicate\n}\n\ninterface OpenPackResponse {\n GrantedCollectibles?: GrantedCollectible[]; // full pull list (incl. duplicates)\n DuplicateCollectibles?: GrantedCollectible[]; // subset that were duplicates\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n NewlyCompletedSetIDs?: string[];\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // pack Cost debit (+ BonusRewardSlots grants)\n TriggeredPity?: unknown[];\n}\n\ninterface OpenCollectionChestResponse {\n GrantedCollectibles?: GrantedCollectible[];\n DuplicateCollectibles?: GrantedCollectible[];\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n Resources?: ResourceOperation; // CollectionCurrencyCost debit (+ bonus grants)\n TriggeredPity?: unknown[];\n}\n\ninterface UseCollectibleJokerResponse {\n GrantedCollectibleID?: string;\n NewlyCompletedSetID?: string;\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // Joker item consumed\n}\n\ninterface ClaimSetRewardResponse {\n SetID: string;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimGrandPrizeResponse {\n Resources?: ResourceOperation;\n}\n\ninterface SendTradeOfferResponse {\n OfferID: string;\n ExpiresAtUtc?: string;\n Resources?: ResourceOperation; // usually absent; trading has no inherent cost\n}\n\ninterface AcceptTradeOfferResponse {\n OfferID: string;\n ReceivedCollectibleID?: string;\n ReceivedIsSpecial?: boolean;\n SentCollectibleID?: string;\n SentCollectibleIsSpecial?: boolean;\n Transfer?: unknown; // server-internal transfer record, not strictly typed\n}\n```\n\n`ClaimSetRewardsBatchResponse` is `BatchItemResult<ClaimSetRewardResponse>[]`\n— see the shared `BatchItemResult<T>` shape\n(`_shared/BatchModels.ts`): `{ Id, Success, Error?, Data? }` per item, one\natomic charge across the whole batch.\n"
|
|
8
|
+
"content": "# Collection data model — reference\n\nFull shape of the config (`CollectionDefinitions`), player state\n(`UserCollectionState`), the pack/chest reward mechanics, and the trade-offer\nlifecycle. Config types are **strictly typed in the SDK** — `CollectionDefinitions`\nand every nested block are exported from `@idosgames/core`. Every schema keeps\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CollectionDefinitions](#config-collectiondefinitions)\n- [CollectionDefinition / Sets / Collectibles](#collectiondefinition--sets--collectibles)\n- [PackTypes and CollectionChests (reward slots + pity)](#packtypes-and-collectionchests)\n- [Duplicate conversion](#duplicate-conversion)\n- [SpecialTradeEvents](#specialtradeevents)\n- [Player state: UserCollectionState](#player-state-usercollectionstate)\n- [Season-linked wipe](#season-linked-wipe)\n- [Trade offers](#trade-offers)\n- [Responses](#responses)\n\n---\n\n## Config: CollectionDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CollectionDefinitions>(\"Collection\")`.\n\n```ts\ninterface CollectionDefinitions {\n Collections?: Record<string, CollectionDefinition> | null; // key = CollectionID\n PackTypes?: Record<string, CollectionPackTypeDefinition> | null; // key = PackTypeID\n CollectionChests?: CollectionChestDefinition[] | null;\n DuplicateConversions?: DuplicateCollectionCurrencyConversion[] | null;\n DailyTradeLimit?: number | null;\n CollectibleJokerCatalogID?: string | null;\n CollectibleJokerItemID?: string | null;\n SpecialTradeEvents?: SpecialTradeEventDefinition[] | null;\n}\n```\n\n`DailyTradeLimit` bounds `sendTradeOffer` calls per calendar day (tracked by\n`UserCollectionState.DailyTradesSent` / `DailyTradesResetDate`, reset at UTC\nmidnight); backend default is **5/day** if the title doesn't set it.\n`CollectibleJokerItemID` (optionally scoped by `CollectibleJokerCatalogID`) is\nthe item burned by `useCollectibleJoker` — grant this item to players through\nthe Item/Store/Lootbox modules; the Collection module only consumes it. It's a\nnormal `InventoryV2.Items` item and does not burn on a season wipe, so players\ncan bank Jokers across seasons.\n\n---\n\n## CollectionDefinition / Sets / Collectibles\n\nThree-level hierarchy: Collection → Set → Collectible.\n\n```ts\ninterface CollectionDefinition {\n CollectionID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n SeasonChainID?: string; // links this collection to a season chain, if any\n Sets?: CollectionSetDefinition[];\n GrandPrize?: ResourceGrant; // claimed once via claimGrandPrize()\n}\n\ninterface CollectionSetDefinition {\n SetID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n SortOrder?: number;\n Collectibles?: CollectibleDefinition[];\n SetCompletionReward?: ResourceGrant; // claimed once via claimSetReward()\n}\n\ninterface CollectibleDefinition {\n CollectibleID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Rarity?: number;\n HasSpecialVersion?: boolean; // a rarer \"Special\" variant exists for this id\n SortOrder?: number;\n}\n```\n\nA Collection is \"completed\" (`IsCollectionCompleted`) once every Set inside it\nis completed; a Set is completed once every listed Collectible has been\nobtained at least once (`OwnedCollectibles[id] >= 1`). Owning duplicates past\n1 does not grant anything further directly — see\n[Duplicate conversion](#duplicate-conversion).\n\n---\n\n## PackTypes and CollectionChests\n\nBoth are openable reward containers priced differently: Packs cost the shared\n`ResourceConsume` type (currency/items/event tokens); Chests are priced purely\nin `CollectionCurrencyCost` (the module's own soft currency, earned from\nduplicates).\n\n```ts\ninterface CollectionPackTypeDefinition {\n PackTypeID?: string;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; the selected one is charged by openPack(), required non-empty or the open is rejected\n BonusRewardSlots?: LootboxRewardSlot[]; // extra non-collectible rewards\n PityRules?: LootboxPityRule[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectibleCount?: number; // how many Collectibles this pack grants (backend default 3)\n GuaranteedMinRarity?: number; // backend default 1\n GuaranteeMaxRarity?: boolean; // backend default false\n RarityWeights?: Record<string, number>; // rarity id (as string \"1\"..\"5\") -> drop weight\n ColorTier?: number; // 1=Green,2=Blue,3=Orange,4=Purple; backend default 1\n}\n\ninterface CollectionChestDefinition {\n CollectionChestID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectionCurrencyCost?: number;\n MinCollectibleCount?: number; // backend default 1\n MaxCollectibleCount?: number; // backend default 2\n GuaranteedMinRarity?: number; // backend default 2\n BonusRewardSlots?: LootboxRewardSlot[];\n PityRules?: LootboxPityRule[];\n Tier?: number; // 1=Bronze,2=Silver,3=Gold; backend default 1\n}\n```\n\n`BonusRewardSlots` / `PityRules` reuse the shared reward-slot primitives from\n`_shared/RewardSlotModels.ts` (the same ones the Lootbox module uses — see\n[lootbox-system](../../lootbox-system/SKILL.md) if you need the full\nslot/pool/pity mechanics):\n\n```ts\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only; no Consume side\n Weight?: number;\n AmountRange?: { Min?: number; Max?: number };\n}\ninterface LootboxRewardSlot {\n SlotID?: string;\n MinRolls?: number; // independent rolls to make over Pool\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxPityRule {\n RuleID?: string;\n Threshold?: number; // every Nth open without a qualifying pull, force one\n Pool?: LootboxRewardRoll[];\n}\n```\n\n**Pack roll (`OpenPack`, per Collectible slot, `Collection.cs`\n`RollCollectiblesForPack`):** slot 0 gets the pack's guarantee — if\n`GuaranteeMaxRarity` is true it forces rarity 5, otherwise it weighted-picks\nfrom `RarityWeights` with a floor of `GuaranteedMinRarity`; every other slot\nweighted-picks with a floor of rarity 1. The weighted pick\n(`PickRarityWeighted`) filters `RarityWeights` entries to `rarity >= floor`,\nsums their weights, and rolls uniformly in `[0, total)` via `SecureRandom`; if\nno candidate matches the exact rarity it widens to `>= targetRarity`, then\nfalls back to the full Collectible pool. A picked Collectible that already has\n`HasSpecialVersion: true` additionally has a **flat 5% chance** to be granted\nas its Special version instead of the normal one (`SecureRandom.Next(0,100) <\n5`) — this 5% is hardcoded, not title-configurable.\n\n**Chest roll (`OpenCollectionChest`, `RollCollectiblesForCollectionChest`):**\npicks a random Collectible count uniformly in\n`[MinCollectibleCount, MaxCollectibleCount]`, then for each pick filters the\npool to `Rarity >= GuaranteedMinRarity` (falling back to the full pool if that\nfilter is empty) and picks uniformly at random. Chests never roll Special\nversions.\n\n**Duplicate detection happens per-roll, in-order, within the same open**: the\n\"already owned\" check adds in any Collectibles already granted earlier in the\n_same_ pack/chest before checking the next slot, so pulling the same\nCollectible twice in one 5-slot pack correctly flags the second as a\nduplicate even though neither has hit the database yet.\n\nPity progress is tracked per rule in\n`UserCollectionState.PityCounters: Record<string, UserLootboxPityCounter>`,\nkeyed by **`\"{PackTypeID or CollectionChestID}:{RuleID}\"`** (literal colon\njoin; `CollectionPityHelpers.CounterKey`) — not by `RuleID` alone, so the same\n`RuleID` reused across two pack types tracks independently. The counter type\nis shared with the Lootbox module. Math per open (count is always 1 for\nCollection, unlike Lootbox's multi-open): `totalSteps = counter + 1`,\n`triggers = totalSteps / Threshold` (0 or 1), `newCounter = totalSteps %\nThreshold` — i.e. classic hard-pity, resets to 0 exactly on the open that\nhits the threshold. `OpenPackResponse` / `OpenCollectionChestResponse` both\ncarry `TriggeredPity: unknown[]` — the response signals _that_ pity fired\n(with `RuleID`, always `BoxIndex: 0` for Collection) but doesn't strictly\ntype the payload shape; treat it as informational (e.g. a \"pity!\" toast)\nrather than something to branch business logic on.\n\n---\n\n## Duplicate conversion\n\n```ts\ninterface DuplicateCollectionCurrencyConversion {\n Rarity?: number;\n CollectionCurrencyGranted?: number;\n}\n```\n\nWhen a pack/chest pull is a Collectible the player already owns, instead of\nstacking uselessly it auto-converts into `CollectionCurrencyGranted` (looked\nup by the pulled Collectible's `Rarity` in this list) — that's the\n`CollectionCurrencyEarned` you see on `OpenPackResponse` /\n`OpenCollectionChestResponse`, and it's what funds `openCollectionChest`. This\nis why chests exist: a way to spend \"wasted\" duplicate pulls on guaranteed\nprogress instead.\n\n**Fallback when no rule matches the rarity:** `GetCollectionCurrencyForDuplicate`\nfalls back to `rarity` itself (i.e. a rarity-3 duplicate grants 3 Collection\nCurrency) if `DuplicateConversions` has no entry for that rarity — so an\nincomplete conversion table doesn't silently grant 0, but also won't match\nwhatever curve you intended. Configure every rarity 1-5 explicitly rather than\nrelying on the fallback.\n\nA duplicate normally caps ownership at effectively 1 (the doc comment on\n`UserCollectionState.OwnedCollectibles` calls `>= 2` a rare/transient state —\nconversion is meant to be immediate) but the code path that increments it is\nplain `Dictionary` arithmetic in memory before the Mongo patch, so treat\n`OwnedCollectibles[id]` as \"0, 1, or rarely-briefly more,\" not a strict\nboolean.\n\n---\n\n## SpecialTradeEvents\n\n```ts\ninterface SpecialTradeEventDefinition {\n SpecialTradeEventID?: string;\n StartUtc?: string;\n EndUtc?: string;\n AllowedSpecialCollectibleIDs?: string[];\n SpecialTradeEventDailyTradeLimit?: number;\n}\n```\n\nSpecial-version Collectibles (`HasSpecialVersion: true` on the base\nCollectible, traded with `collectibleIsSpecial: true`) can only move via\n`sendTradeOffer` while an active event's window covers `now` **and** lists\nthat Collectible in `AllowedSpecialCollectibleIDs`. Outside any such window,\noffering a Special is rejected server-side. The event also carries its own\ndaily limit distinct from the title-wide `DailyTradeLimit`.\n\n---\n\n## Player state: UserCollectionState\n\nReturned by `getUserState()`; cached at `client.data.user.state?.Collection`.\n**Loosely typed** (`z.object({}).passthrough()` cast to the interface) —\nunlike the config side, this is not field-validated, so treat it as\nbest-effort and read defensively.\n\n```ts\ninterface UserCollectionState {\n CollectionID?: string;\n SeasonVersion?: number;\n CollectionCurrencyBalance?: number;\n TotalCollectionCurrencyEarned?: number;\n OwnedCollectibles?: Record<string, number>; // CollectibleID -> count owned\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[]; // SetIDs already claimed\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n DailyTradesSent?: number;\n DailyTradesResetDate?: string;\n PendingTradeOfferIDs?: string[];\n PityCounters?: Record<string, UserLootboxPityCounter>; // key = \"{PackTypeID|CollectionChestID}:{RuleID}\"\n}\n```\n\n`SeasonVersion` defaults to `0` when the collection isn't season-linked.\n`PityCounters` (like `OwnedCollectibles`/`OwnedSpecialCollectibles`) is a plain\ndictionary that only gains a key the first time that pool triggers — treat a\nmissing key as counter `0`, not an error.\n\n---\n\n## Season-linked wipe\n\nA `CollectionDefinition` may set `SeasonChainID` to bind itself to a season\nchain (`Season` module). Every Collection action re-derives the \"current\"\n`(activeCollectionID, SeasonVersion)` pair on each call\n(`EnsureCollectionWipedIfNeededAsync` in `Collection.cs`):\n\n- If any season chain has a `LinkedCollectionID` whose window is currently\n active (not paused), that collection is the active one, and\n `SeasonVersion = CycleIndex * 1000 + SeasonOrder` of that window.\n- Otherwise, if the title has no season-linked collection, the **first**\n collection in config-declaration order (`Collections.Keys.First()`) is used\n with `SeasonVersion = 0`.\n\nIf the player's stored `UserCollectionState.CollectionID` /\n`SeasonVersion` doesn't match, the **entire** Collection state is wiped and\nreplaced with a fresh zeroed one (new `CollectionID`, `SeasonVersion`, empty\n`OwnedCollectibles`/`OwnedSpecialCollectibles`/`ClaimedSetRewards`/\n`PendingTradeOfferIDs`, zeroed currency, `IsCollectionCompleted`/\n`GrandPrizeClaimed` reset to `false`) — this happens **lazily**, on the very\nnext Collection call the player makes after the season rolls over, not on a\nschedule. There is no dedicated wipe event; the wiped state is simply what\nthe next `getUserState()` (or any other Collection call) returns. Design\naround this: don't assume a cached `Collection` state survives across a\nsession gap without a fresh fetch, and don't build UI that depends on\n`OwnedCollectibles` persisting across a season boundary for a season-linked\ncollection.\n\n---\n\n## Trade offers\n\n```ts\ninterface CollectionTradeOfferDocument {\n OfferID: string;\n TitleID?: string;\n CollectionID?: string;\n SenderUserID?: string;\n SenderPublicData?: UserPublicDataModel; // sender's public profile snapshot\n OfferedCollectibleID?: string;\n OfferedCollectibleIsSpecial?: boolean;\n ReceiverUserID?: string;\n RequestedCollectibleID?: string; // absent = open/gift offer, no ask-back\n RequestedCollectibleIsSpecial?: boolean;\n Status?: \"Pending\" | \"Accepted\" | \"Declined\" | \"Cancelled\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RespondedAtUtc?: string;\n DeclineReason?: string;\n IsSpecialTradeEvent?: boolean;\n SpecialTradeEventID?: string;\n}\n```\n\n**Preconditions checked by `sendTradeOffer` (`Collection.cs` `SendTradeOffer`),\nin order:** `ReceiverUserID` can't equal your own `UserID` (\"Cannot trade with\nyourself\"); the receiver must be **your friend** — the server checks the social\ngraph directly (a point lookup on the friendship edge), NOT a list inside the\nplayer document, so it is authoritative even if your local\n`Social.Accepted` cache was never loaded (\"Receiver must be in your friends\nlist\" — see [social-system](../../social-system/SKILL.md)); if `CollectibleIsSpecial` a\nmatching active `SpecialTradeEventDefinition` must exist (\"Special\nCollectibles can only be traded during an active SpecialTradeEvent\"); the\neffective daily limit (event-specific limit if trading a Special during its\nevent window, else the title's `DailyTradeLimit`, lazily reset at UTC\nmidnight) must not already be hit (\"Daily trade limit reached (N/day)\"); you\nmust hold enough of the offered Collectible — **`>= 2`** for a normal\nCollectible (you keep one, offer the spare) or **`>= 1`** for a Special (the\nwhole thing moves, no spare kept back); and the receiver's\n`PendingTradeOfferIDs` must have fewer than **10** entries\n(`MaxPendingIncomingOffers`) — \"Receiver has too many pending trade offers.\"\nThe same `>= 2` (normal) / `>= 1` (Special) ownership check re-runs against\nthe **receiver's** balance for `RequestedCollectibleID` at `acceptTradeOffer`\ntime, since their holdings may have changed since the offer was sent.\n\nLifecycle: `sendTradeOffer` creates a document with `Status: \"Pending\"`,\n`CreatedAtUtc: now`, and `ExpiresAtUtc: now + 168h` (7 days —\n`TradeOfferExpirationHours` in `Collection.cs`, not title-configurable). The\nreceiver calls `getIncomingTradeOffers` to see it, then either\n`acceptTradeOffer` (→ `Status: \"Accepted\"`, both Collectibles swap owners; also\nrejected if `ExpiresAtUtc <= now`, \"Offer has expired\") or `declineTradeOffer`\n(→ `Status: \"Declined\"`). The sender can `cancelTradeOffer` any offer still\n`\"Pending\"` (→ `Status: \"Cancelled\"`).\n\n**`\"Expired\"` is a declared `Status` value the backend never actually\nwrites** — there is no sweep job that flips stale offers to `Expired`.\n`getIncomingTradeOffers` filters server-side to `Status == \"Pending\" &&\nExpiresAtUtc > now`, so an expired incoming offer just silently drops out of\nthat list (it doesn't surface with a distinguishable status). `getMyTradeOffers`\n(outgoing) has **no such filter** — it returns everything you've ever sent for\nthat collection (newest 20), so a lapsed offer you sent still reads\n`Status: \"Pending\"` with an `ExpiresAtUtc` in the past; compare `ExpiresAtUtc`\nagainst the current time yourself if you need to grey it out in a \"my offers\"\nlist. None of the four trade actions mutate `client.data.user` directly (no\n`Resources`, no `Collection` cache patch) — re-fetch `getUserState()` / the\noffer lists to observe the effect.\n\n---\n\n## Responses\n\n```ts\ninterface GrantedCollectible {\n CollectibleID: string;\n Rarity?: number;\n IsSpecial?: boolean;\n IsDuplicate?: boolean;\n CollectionCurrencyConverted?: number; // set when IsDuplicate\n}\n\ninterface OpenPackResponse {\n GrantedCollectibles?: GrantedCollectible[]; // full pull list (incl. duplicates)\n DuplicateCollectibles?: GrantedCollectible[]; // subset that were duplicates\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n NewlyCompletedSetIDs?: string[];\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // pack Cost debit (+ BonusRewardSlots grants)\n TriggeredPity?: unknown[];\n}\n\ninterface OpenCollectionChestResponse {\n GrantedCollectibles?: GrantedCollectible[];\n DuplicateCollectibles?: GrantedCollectible[];\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n Resources?: ResourceOperation; // CollectionCurrencyCost debit (+ bonus grants)\n TriggeredPity?: unknown[];\n}\n\ninterface UseCollectibleJokerResponse {\n GrantedCollectibleID?: string;\n NewlyCompletedSetID?: string;\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // Joker item consumed\n}\n\ninterface ClaimSetRewardResponse {\n SetID: string;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimGrandPrizeResponse {\n Resources?: ResourceOperation;\n}\n\ninterface SendTradeOfferResponse {\n OfferID: string;\n ExpiresAtUtc?: string;\n Resources?: ResourceOperation; // usually absent; trading has no inherent cost\n}\n\ninterface AcceptTradeOfferResponse {\n OfferID: string;\n ReceivedCollectibleID?: string;\n ReceivedIsSpecial?: boolean;\n SentCollectibleID?: string;\n SentCollectibleIsSpecial?: boolean;\n Transfer?: unknown; // server-internal transfer record, not strictly typed\n}\n```\n\n`ClaimSetRewardsBatchResponse` is `BatchItemResult<ClaimSetRewardResponse>[]`\n— see the shared `BatchItemResult<T>` shape\n(`_shared/BatchModels.ts`): `{ Id, Success, Error?, Data? }` per item, one\natomic charge across the whole batch.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|