@idosgames/mcp 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +20 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +5 -5
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/acquisition-attribution.json +6 -0
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/game-loop-system.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/lootbox-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +1 -1
- package/registry/skills/purchase-system.json +2 -2
- package/registry/skills/referral-system.json +3 -3
- package/registry/skills/reward-system.json +1 -1
- package/registry/skills/social-system.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idosgames/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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.16\",\n \"@idosgames/core\": \"0.10.0\",\n \"@idosgames/module-sdk\": \"0.1.11\",\n \"@idosgames/react\": \"0.2.3\",\n \"@idosgames/wallet\": \"0.2.3\",\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 { 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"
|
|
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(\n humanizeAuthError(result.error) ?? \"Sign-in failed. Please try again.\",\n );\n setBusy(false);\n };\n\n /**\n * Регистрация — единственный провайдер, который НЕ обязательно заканчивается входом.\n *\n * Когда тайтл требует подтверждения адреса (а это значение платформы), сервер только отправляет\n * код, и аккаунта ещё нет. Поэтому она идёт мимо `run`: тот на успехе сразу зовёт\n * `onAuthenticated()`, а здесь на успехе надо показать экран ввода кода.\n */\n const register = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n client.auth.setRememberSession(remember);\n\n const result = await client.auth.registerWithEmail(email, password);\n\n if (!result.ok) {\n setError(\n humanizeAuthError(result.error) ?? \"Sign-up failed. Please try again.\",\n );\n setBusy(false);\n return;\n }\n\n // Аккаунта ещё нет — он появится на подтверждении. Уйти в игру здесь значило бы показать\n // пустую сессию.\n setCode(\"\");\n\n // Паузу задаёт СЕРВЕР (её настраивает издатель), поэтому запоминаем её и дальше берём\n // отсюда. Раньше первый отсчёт шёл от ответа, а каждый следующий — от захардкоженных 60\n // секунд: у тайтла с другой настройкой кнопка либо открывалась раньше, чем сервер согласен\n // слать (нажатие впустую, ответ всё равно успешный), либо держалась закрытой дольше нужного.\n const cooldown = result.data.resendCooldownSeconds ?? 0;\n setResendCooldown(cooldown > 0 ? cooldown : DEFAULT_RESEND_COOLDOWN);\n\n setResendIn(cooldown);\n setMode(\"verify\");\n setBusy(false);\n };\n\n const resendCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n\n const result = await client.auth.resendVerificationCode(email);\n\n // Ответ почти всегда успешный — начата регистрация или нет, выдержана пауза или нет. Иначе\n // эта кнопка отвечала бы на вопрос «заведён ли такой адрес». Поэтому и таймер заводим всегда.\n //\n // ⚠ Но ОДИН отказ отсюда приходит и его нельзя глотать: «слать нечем» (у тайтла и у\n // платформы нет отправителя). Он про конфигурацию сервера, а не про адрес, поэтому и\n // безопасен, и обязателен — иначе игрок жмёт кнопку до посинения, ожидая письма, которого\n // никто не отправлял.\n if (!result.ok) setError(humanizeAuthError(result.error));\n\n setResendIn(resendCooldown);\n setBusy(false);\n };\n\n // Обратный отсчёт до следующей отправки. Без него игрок жмёт «ещё раз» вслепую, а сервер молча\n // отказывает — и выглядит это как сломанная кнопка.\n useEffect(() => {\n if (resendIn <= 0) return;\n const id = setTimeout(() => setResendIn((v) => v - 1), 1000);\n return () => clearTimeout(id);\n }, [resendIn]);\n\n const signInWithGoogle = (): void => {\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: ENV_GOOGLE_CLIENT_ID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: rgba(255, 255, 255, 0.65); }`}</style>\n <div style={styles.card}>\n <img src={LOGO_DATA_URL} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: { ...styles.button, ...styles.primary },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {isSsoAvailable() && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {ENV_GOOGLE_CLIENT_ID && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={signInWithGoogle}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </button>\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n registering\n ? void register()\n : void run(() => client.auth.loginWithEmail(email, password))\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"verify\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n We sent a code to <strong>{email}</strong>. Enter it to finish\n creating your account.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Confirmation code\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n client.auth.confirmEmailRegistration(email, code),\n )\n }\n disabled={busy || !code}\n >\n {busy ? \"Please wait…\" : \"Confirm\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void resendCode()}\n disabled={busy || resendIn > 0}\n >\n {resendIn > 0\n ? `Send again in ${resendIn}s`\n : \"Send the code again\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {/* Applies to every provider above. A wallet sign-in ignores it — those are never\n restored silently, a fresh signature is required on each launch. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? \"#fff\" : \"rgba(255, 255, 255, 0.3)\",\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n background: remember ? \"#0d66fe\" : \"#fff\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// Brand look: iDos Games blue with the white logo; controls are translucent white on top of it,\n// the primary action is solid white with blue text.\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"#0d66fe\",\n color: \"#fff\",\n font: \"14px system-ui, sans-serif\",\n },\n card: { width: \"min(340px, 88vw)\", display: \"grid\", gap: \"18px\" },\n logo: {\n width: \"180px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: \"rgba(255, 255, 255, 0.85)\",\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n transition: \"left 0.15s, background 0.15s\",\n },\n title: { margin: 0, fontSize: \"22px\", fontWeight: 600, textAlign: \"center\" },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.4)\",\n background: \"rgba(255, 255, 255, 0.14)\",\n color: \"inherit\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n primary: {\n background: \"#fff\",\n borderColor: \"#fff\",\n color: \"#0d66fe\",\n fontWeight: 600,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: \"rgba(255, 255, 255, 0.85)\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n input: {\n padding: \"11px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.35)\",\n background: \"rgba(255, 255, 255, 0.12)\",\n color: \"inherit\",\n font: \"inherit\",\n },\n error: { margin: 0, color: \"#ffd7d7\", textAlign: \"center\" },\n hint: {\n margin: 0,\n color: \"rgba(255, 255, 255, 0.85)\",\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
|
|
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": "cf76ac1511b491d8ad9852d9b74202b17fc55d36",
|
|
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.10.0",
|
|
5
|
+
"@idosgames/wallet": "0.2.3",
|
|
6
|
+
"@idosgames/module-sdk": "0.1.11",
|
|
7
|
+
"@idosgames/react": "0.2.3",
|
|
8
|
+
"@idosgames/app-shell": "0.1.16"
|
|
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.10.0",
|
|
50
|
+
"@idosgames/module-sdk": "0.1.11",
|
|
51
|
+
"@idosgames/react": "0.2.3",
|
|
52
|
+
"@idosgames/wallet": "0.2.3",
|
|
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.10.0",
|
|
96
|
+
"@idosgames/module-sdk": "0.1.11",
|
|
97
|
+
"@idosgames/react": "0.2.3",
|
|
98
|
+
"@idosgames/wallet": "0.2.3",
|
|
99
99
|
"@tanstack/react-query": "5.101.2",
|
|
100
100
|
"phaser": "4.2.1",
|
|
101
101
|
"react": "19.2.7",
|
|
@@ -138,13 +138,17 @@
|
|
|
138
138
|
},
|
|
139
139
|
"version": "0.1.0",
|
|
140
140
|
"dependencies": {
|
|
141
|
-
"@idosgames/module-sdk": "0.1.
|
|
141
|
+
"@idosgames/module-sdk": "0.1.11",
|
|
142
142
|
"three": "0.185.1"
|
|
143
143
|
},
|
|
144
144
|
"fileCount": 38
|
|
145
145
|
}
|
|
146
146
|
],
|
|
147
147
|
"skills": [
|
|
148
|
+
{
|
|
149
|
+
"name": "acquisition-attribution",
|
|
150
|
+
"description": "Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core) knows where a player came from, and how playtime reaches the publisher's analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite codes / idos_click tokens / Telegram start_param at launch, delivery with the login request, the deferred install match, the universal idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and retention. Use this whenever the user asks about attribution, UTM tags, ad campaigns, install tracking, \"where did this player come from\", invite links, deep links carrying a referral code, session counting, playtime, DAU or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL or localStorage for campaign or referral parameters."
|
|
151
|
+
},
|
|
148
152
|
{
|
|
149
153
|
"name": "authentication",
|
|
150
154
|
"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."
|
|
@@ -255,7 +259,7 @@
|
|
|
255
259
|
},
|
|
256
260
|
{
|
|
257
261
|
"name": "referral-system",
|
|
258
|
-
"description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (who they're subscribed to, follower count, claimed invite rewards), activate someone else's
|
|
262
|
+
"description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (their own SHORT invite code, a ready-made invite link, who they're subscribed to, follower count, claimed invite rewards), activate someone else's code, and claim a staged invite reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants invite-friend / referral-code / refer-a-friend UIs, follower-milestone reward screens, or otherwise touches client.referral, ReferralService, ReferralDefinitions, UserReferralState, or referral codes — even if they don't name the module explicitly. Also use it for \"share my invite link\", \"enter a friend's code\" and invite-code screens."
|
|
259
263
|
},
|
|
260
264
|
{
|
|
261
265
|
"name": "reward-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.10.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.11",
|
|
42
|
+
"@idosgames/react": "0.2.3",
|
|
43
|
+
"@idosgames/wallet": "0.2.3",
|
|
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.10.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.11",
|
|
42
|
+
"@idosgames/react": "0.2.3",
|
|
43
|
+
"@idosgames/wallet": "0.2.3",
|
|
44
44
|
"@tanstack/react-query": "5.101.2",
|
|
45
45
|
"phaser": "4.2.1",
|
|
46
46
|
"react": "19.2.7",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
"path": "data/characterConfig.ts",
|
|
81
|
-
"content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\
|
|
81
|
+
"content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\nimport { curveMultiplier, evaluateCurve, roundAmount } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface PriceOption {\n Cost?: {\n Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] };\n };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n /** Growth of the value over the stat's own level; the step is numbered from **0**. */\n ValueCurve?: ScalarCurveSpec;\n /** Cost curve; the step is the TARGET level, numbered from 1. */\n CostCurve?: ScalarCurveSpec;\n /** Ways to pay; key = the option id. Replaced the removed `BaseCostResource`. */\n PriceOptions?: Record<string, PriceOption>;\n}\n\nexport interface StatsPreset {\n Stats?: Record<string, StatDefinition>;\n}\n\nexport interface CharacterPresetBindings {\n Stats?: PresetBinding;\n Levels?: PresetBinding;\n Equipment?: PresetBinding;\n}\n\nexport interface CharacterDefinition {\n CharacterID?: string;\n Presets?: CharacterPresetBindings;\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n Stats?: Record<string, StatDefinition>;\n Classification?: { ClassID?: string; RarityID?: string };\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\n Unlock?: { UnlockedByDefault?: boolean };\n}\n\nexport interface CharacterPresetRegistry {\n Stats?: Record<string, StatsPreset>;\n Equipment?: Record<string, EquipmentPreset>;\n}\n\nexport interface CharacterConfig {\n Definitions?: Record<string, CharacterDefinition>;\n Presets?: CharacterPresetRegistry;\n}\n\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\n return client.data.config.getSection<CharacterConfig>(\"Character\") ?? {};\n}\n\nexport interface SlotInfo {\n slotID: string;\n minCharacterLevel: number;\n}\n\n/**\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveSlots(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): SlotInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const presetSlots = binding?.PresetID\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\n return Object.entries(slots).map(([slotID, rule]) => ({\n slotID,\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\n }));\n}\n\nexport interface StatInfo {\n statID: string;\n displayName: string;\n maxLevel?: number;\n baseValue: number;\n valueCurve?: ScalarCurveSpec;\n costCurve?: ScalarCurveSpec;\n costAmount: number;\n costCurrency?: string;\n}\n\n/**\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\n * StatID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveStats(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const presetStats = binding?.PresetID\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\n return Object.entries(stats).map(([statID, rule]) => {\n // The first option's first entry, mirroring the server's \"default cost\" for shop-like\n // displays (`PriceOptionSelector.DefaultCost`): the player has not chosen a way to pay yet.\n const option = Object.values(rule.PriceOptions ?? {})[0];\n const entry = option?.Cost?.Standard?.Entries?.[0];\n return {\n statID,\n displayName: rule.DisplayName ?? statID,\n maxLevel: rule.MaxLevel,\n baseValue: rule.BaseStatValue ?? 0,\n valueCurve: rule.ValueCurve,\n costCurve: rule.CostCurve,\n costAmount: entry?.Amount ?? 0,\n costCurrency: entry?.CurrencyID,\n };\n });\n}\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\nfunction mergeByKey<T>(\n preset: Record<string, T> | undefined,\n inline: Record<string, T> | undefined,\n remove: string[] | undefined,\n): Record<string, T> {\n if (!preset) return inline ?? {};\n const result: Record<string, T> = { ...preset, ...inline };\n for (const key of remove ?? []) delete result[key];\n return result;\n}\n\n/**\n * Effective stat value at a given level — the shared curve, evaluated exactly as the\n * server does it in `PvPBattleEngine.CalculateStats`.\n *\n * The step is the stat level with **firstStep = 0**: a stat the player never upgraded is\n * level 0 and is worth the plain base. This used to be a third hand-written copy of the\n * formula here, and it was wrong in two ways at once — it assumed the multiplicative\n * shape (the server's is whatever the publisher configured) and counted from level 1, so\n * every stat was displayed one step behind what the battle actually used.\n */\nexport function computeStatValue(stat: StatInfo, level: number): number {\n return evaluateCurve(stat.valueCurve, stat.baseValue, level, 0);\n}\n\n/**\n * Cost to upgrade a stat TO `nextLevel`. The step is the TARGET level, numbered from 1,\n * and the total is rounded UP once — the platform's single rounding convention. The old\n * `Math.round` here disagreed with the server by one unit on half the levels.\n */\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\n if (stat.costAmount <= 0) return 0;\n return roundAmount(\n stat.costAmount * curveMultiplier(stat.costCurve, nextLevel, 1),\n );\n}\n\nexport type Availability = \"owned\" | \"available\" | \"locked\";\n\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\n * (first level-up creates the model); locked = must be unlocked. */\nexport function resolveAvailability(\n def: CharacterDefinition | undefined,\n hasModel: boolean,\n): Availability {\n if (hasModel) return \"owned\";\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\n return \"locked\";\n}\n"
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"path": "env.ts",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acquisition-attribution",
|
|
3
|
+
"description": "Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core) knows where a player came from, and how playtime reaches the publisher's analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite codes / idos_click tokens / Telegram start_param at launch, delivery with the login request, the deferred install match, the universal idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and retention. Use this whenever the user asks about attribution, UTM tags, ad campaigns, install tracking, \"where did this player come from\", invite links, deep links carrying a referral code, session counting, playtime, DAU or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL or localStorage for campaign or referral parameters.",
|
|
4
|
+
"content": "---\nname: acquisition-attribution\ndescription: >-\n Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core)\n knows where a player came from, and how playtime reaches the publisher's\n analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite\n codes / idos_click tokens / Telegram start_param at launch, delivery with\n the login request, the deferred install match, the universal\n idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and\n retention. Use this whenever the user asks about attribution, UTM tags, ad\n campaigns, install tracking, \"where did this player come from\", invite\n links, deep links carrying a referral code, session counting, playtime, DAU\n or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL\n or localStorage for campaign or referral parameters.\n---\n\n# Acquisition and attribution (iDosGames TS SDK)\n\nTwo things run by themselves in every client created with\n`createIDosGamesClient`, and **the correct amount of code you write for either\nis zero**:\n\n1. **`AcquisitionCapture`** reads the launch URL (and Telegram launch\n parameters) the moment the client is created, keeps what it found, and\n attaches it to whichever sign-in the player eventually uses.\n2. **`PlaytimeTracker`** counts how long the player actually plays and reports\n it, starting at login.\n\nThis skill exists mostly so you do **not** re-implement either one. If you find\nyourself writing `new URLSearchParams(location.search).get(\"utm_source\")` or a\n`setInterval` that posts playtime, stop: the SDK already did it, and a second\nimplementation competes with the first.\n\n## Why it matters\n\nEvery acquisition number a publisher sees — which campaign brought which\nplayer, retention split by source, invite conversion, K-factor — is derived\nfrom a signal the client sends **once**, with the login. And every engagement\nnumber — DAU, WAU, MAU, stickiness, average session, the retention cohorts on\ntop of them — is derived from what the playtime tracker posts. Neither has a\nfallback: nothing else on the platform writes those records.\n\n## What gets captured\n\nAt client construction, from the launch URL and the platform adapter:\n\n| Source | Lands in |\n| ---------------------------------------------------- | ----------------------------------------------------------- |\n| `utm_source/medium/campaign/term/content` | the matching `Utm*` fields |\n| `gclid`, `fbclid`, `ttclid`, `msclkid`, `yclid` | `ClickID` (whichever appears; ad networks never mix theirs) |\n| `?ref=` / `?r=` (bare code, or with a `ref_` prefix) | `ReferralCode`, `ChannelHint: \"query\"` |\n| `idos_click` | `ClaimToken` — an exact click receipt we issued |\n| Telegram `start_param` | `ReferralCode`, `ChannelHint: \"telegram\"` |\n| `document.referrer` | `Referrer` |\n\nPlus, on **every** login regardless of tags: `OsVersion`, and `AppVersion` if\nyou set one.\n\n`AppVersion` is the only piece the SDK cannot find on its own — the web has no\n`Application.version` — so pass it when you create the client:\n\n```ts\nconst client = createIDosGamesClient({\n titleID: \"MYTITLE\",\n appVersion: \"1.4.2\",\n});\n```\n\nLeave it out and the player is attributed without a build number, which makes\n\"did the 1.5 release change retention?\" unanswerable for that title.\n\n⚠ **Those device facts are not decoration and must not be stripped as\n\"empty signal\".** They are how the server matches an install back to a click\nthat happened in a browser before the app existed (see Deferred match). The\nserver deliberately does not treat them as a signal on their own — that is what\nlets the match run at all.\n\nThe captured signal is **persisted with a 30-day TTL**, because the login often\nhappens much later than the launch: after a redirect to a sign-in screen, after\nan e-mail confirmation, after a reload. Holding it in memory would lose it for\nexactly the players who arrived through a campaign or an invite. It is cleared\nafter a successful login — the next sign-in by the same person must not be\nre-attributed to a month-old campaign.\n\n## The methods you may actually call\n\n```ts\nimport { AcquisitionCapture } from \"@idosgames/core\";\n```\n\n| Call | When you need it |\n| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| `AcquisitionCapture.parseReferralCode(input)` | A player pasted something into an \"enter a code\" box. Accepts a bare code, a code with separators, or the whole invite link. |\n| `AcquisitionCapture.detectDevicePlatform(ua)` | Only if you are building a landing page that registers clicks. See the warning below. |\n\nEverything else on the instance (`capture()`, `buildForLogin()`, `clear()`) is\ndriven by the client. Calling them yourself does not add data; `clear()` in\nparticular throws away a signal that has not been delivered.\n\n`client.referral.activateReferralCode` already runs `parseReferralCode` on its\ninput, so for the ordinary \"enter a friend's code\" field you do not even need\nthat — pass the raw text straight through.\n\n## Playtime\n\n`PlaytimeTracker` starts on `auth:loggedIn` and stops on `auth:loggedOut`.\nNothing to wire.\n\n- Flushes every **200 s**; a gap of **300 s** with the tab hidden ends the\n session. Both numbers are copied from the Unity SDK on purpose: different\n constants would make the same behaviour produce different session counts per\n platform, and a publisher comparing web against mobile would be comparing two\n different definitions of \"a session\".\n- Time with the tab in the background is **not** counted.\n- The buffer survives a reload, and a failed flush is put back rather than\n dropped — under-reported playtime is invisible downstream, so it is never\n discarded silently.\n\n## Deferred match — how a store install finds its click\n\nThere is no way to carry a parameter through an app store. So:\n\n1. The universal link `idosgames.com/go/{titleID}?ref=CODE` registers the click\n with the backend and gets a `ClaimToken`.\n2. Where we control the destination (the web build), the token travels in the\n URL and the match is **exact**.\n3. Where we do not (Google Play, App Store), the server matches the first\n launch to the click by a **fingerprint**: network, platform family, major OS\n version, country, plus a daily salt.\n\n⚠ **A fingerprint match is a guess and is reported as one.** Behind a mobile\ncarrier's NAT hundreds of people share it. Such touches are marked\n`Trust: \"Inferred\"` and shown on their own row in the publisher's report — do\nnot build UI that presents them as certain.\n\n⚠ **If you build a page that registers clicks, report the platform of the\nDEVICE (`\"Android\"` / `\"iOS\"`), never `\"Web\"`.** The fingerprint is compared\nbetween two different programs — your page in a browser and the installed game\n— and the game reports its own platform. Send `\"Web\"` from a phone and the keys\nnever line up, which turns the whole deferred match into dead code in exactly\nits main case. `AcquisitionCapture.detectDevicePlatform(navigator.userAgent)`\nreturns the right string.\n\n## Gotchas\n\n- **Do not read the URL yourself for campaign or referral parameters.** The SDK\n captured them at construction and the launch URL is frequently gone by the\n time your screen mounts. Read `client.data.user.state?.Referral` for the\n outcome instead.\n- **Do not build invite links from a template.** The server returns a\n ready-made one (`UserReferralStateResponse.InviteUrl`); a client-assembled\n link is an open redirect, and every title would word it differently. See the\n `referral-system` skill.\n- **The link always carries the title** (`/go/{titleID}?ref=...`) because a\n referral code is unique only inside a title. A link shaped like `/i/{code}`\n cannot exist.\n- **`/go/{titleID}` is deliberately not `/play/...`** — the platform publishes\n applications as well as games.\n- **A game embedded in an iframe does not inherit the page's query string.**\n If you host a build inside your own page, forward `ref` and `idos_click` into\n the iframe `src` yourself, or arrivals through an invite will look like\n ordinary launches — silently.\n- **Telegram `start_param` is the one channel the server actually trusts** (it\n arrives inside data signed by the publisher's bot). The server reads its own\n copy; a code you place in the request body is ignored on a Telegram sign-in,\n so do not try to override it.\n- **Nothing here works before login.** The signal rides on the sign-in request;\n a title that never signs a player in reports nothing, and its DAU stays zero.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
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⚠ 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"
|
|
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": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterRankLadder`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterRankLadder (ranks)](#characterrankladder-the-rank-ladder)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<string, { RankLadder?: CharacterRankLadder }>;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n PriceOptions?: Record<string, PriceOption>; // empty/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n RankLadder?: CharacterRankLadder; // how many ranks and how the price grows\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `PriceOptions` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `PriceOptions` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n## Prices are `PriceOptions`, not a single cost\n\nEvery price in this module is a **dictionary of payment options** (`PriceOptions`), keyed by\n`OptionID`. One option = one way to pay; the entries inside an option's `Cost` are charged\ntogether. Pick one with `SelectedOptionID`, or omit it and the server takes the first option\navailable on the caller's platform — which is why a single-price character needs no client change.\n\nRender options through `client.checkout.availableOptions(...)`: an option may be restricted to\nsome platforms (`AllowedPlatforms`), and an option whose `Cost` holds a `Purchase` entry is paid\nwith a **store receipt**, not from balances. Unlock accepts store payment; stat and rank upgrades\ndo not — their price grows by a formula per level, and a store SKU is a fixed tier. See the\n`checkout-system` skill.\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n PriceOptions: Record<string, PriceOption>; // ways to pay for level 1 (must be non-empty)\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n ValueCurve?: ScalarCurveSpec; // growth over the stat's OWN level; step from 0\n RankCurve?: ScalarCurveSpec; // growth over the character's rank; step from 1\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nEvery growth here is a `ScalarCurveSpec` — the platform's shared curve. **An unset\ncurve is the identity**, so a stat with no `ValueCurve` simply does not grow when\nupgraded; there is no field whose neutral value is `1`. The shapes are `Flat`,\n`PerStep` (units per step), `PerStepRate` (share of the base per step), `Geometric`,\n`Table`, `TableAbsolute`. See the `curve-system` notes in the core package\n(`evaluateCurve`, exported from `@idosgames/core`) rather than re-deriving them.\n\nFormulas (the backend applies these; use `evaluateCurve` for previews):\n\n- **Cost of level `N`** (N = target level): each entry's base `Amount` put through\n `CostCurve` with `firstStep = 1`, rounded **UP**, once, at the end.\n- **Effective max level** = `MaxLevel` put through the character's\n `StatMaxLevelCurve` at its current rank (`firstStep = 1`), rounded up. This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** = `ValueCurve(BaseStatValue, N)`\n with **`firstStep = 0`**, then `RankCurve(thatValue, R)` with `firstStep = 1`, then the\n character-wide `RankStatCurve(R)`.\n ⚠ The stat level counts from **0** — a stat the player never upgraded is level 0 and is\n worth the plain base — while ranks, prices and item levels count from 1. Using the wrong\n first step shifts every value one step along the curve.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\nAn option's `Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterRankLadder (the rank ladder)\n\n```ts\ninterface CharacterRankLadder {\n MaxRank?: number; // how many ranks in total; empty = no ceiling\n FirstPaidRank?: number; // ranks below it are free; empty = 1\n PriceOptions?: Record<string, PriceOption>; // price of the first PAID rank\n CostCurve?: ScalarCurveSpec; // price growth; step = the rank, FirstPaidRank = first step\n}\n```\n\nLives on `CharacterDefinition.RankLadder`, or on the levels preset (`LevelsPreset.RankLadder`)\nwhen the character binds one. **It is the only source of rank prices** — there is no per-rank\ndictionary, and a character without a ladder cannot be ranked up at all.\n\nThe inline ladder **replaces** the preset one whole rather than merging field by field: it is one\nobject (\"how many ranks and how the price grows\"), and taking the ceiling from one place and the\nprice from another has no meaning.\n\n⚠ **A non-uniform ladder is expressed by the curve shape**, not by per-rank entries: `Table` and\n`TableAbsolute` place a multiplier per rank. What a ladder cannot express is a different price\nCOMPOSITION per rank (rank 5 paid in a special item, the rest in gold) — the composition is fixed\nby `PriceOptions` and the curve only scales the amount.\n\n⚠ **`FirstPaidRank` is how a free activation is expressed.** Level `0` means the character exists\nbut is not activated; in most titles the 0 → 1 step is free because the character itself is paid\nfor in `Unlock.PriceOptions`. Ranks below `FirstPaidRank` cost nothing and do **not** stop the\nclimb. The curve starts counting at `FirstPaidRank`, so the base is the price of the first rank\nthat actually costs something.\n\n`upgradeCharacterLevel` moves the character from its current level to the next one and charges the\nselected option of the ladder's `PriceOptions`, scaled by `CostCurve` at that rank.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the character's `RankStatCurve` at the current rank;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
|
|
8
|
+
"content": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterRankLadder`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterRankLadder (ranks)](#characterrankladder-the-rank-ladder)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<string, { RankLadder?: CharacterRankLadder }>;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n PriceOptions?: Record<string, PriceOption>; // empty/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n RankLadder?: CharacterRankLadder; // how many ranks and how the price grows\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `PriceOptions` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `PriceOptions` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n## Prices are `PriceOptions`, not a single cost\n\nEvery price in this module is a **dictionary of payment options** (`PriceOptions`), keyed by\n`OptionID`. One option = one way to pay; the entries inside an option's `Cost` are charged\ntogether. Pick one with `SelectedOptionID`, or omit it and the server takes the first option\navailable on the caller's platform — which is why a single-price character needs no client change.\n\nRender options through `client.checkout.availableOptions(...)`: an option may be restricted to\nsome platforms (`AllowedPlatforms`), and an option whose `Cost` holds a `Purchase` entry is paid\nwith a **store receipt**, not from balances. Unlock accepts store payment; stat and rank upgrades\ndo not — their price grows by a formula per level, and a store SKU is a fixed tier. See the\n`checkout-system` skill.\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n PriceOptions: Record<string, PriceOption>; // ways to pay for level 1 (must be non-empty)\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n ValueCurve?: ScalarCurveSpec; // growth over the stat's OWN level; step from 0\n RankCurve?: ScalarCurveSpec; // growth over the character's rank; step from 1\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nEvery growth here is a `ScalarCurveSpec` — the platform's shared curve. **An unset\ncurve is the identity**, so a stat with no `ValueCurve` simply does not grow when\nupgraded; there is no field whose neutral value is `1`. The shapes are `Flat`,\n`PerStep` (units per step), `PerStepRate` (share of the base per step), `Geometric`,\n`Table`, `TableAbsolute`. See the `curve-system` notes in the core package\n(`evaluateCurve`, exported from `@idosgames/core`) rather than re-deriving them.\n\nFormulas (the backend applies these; use `evaluateCurve` for previews):\n\n- **Cost of level `N`** (N = target level): each entry's base `Amount` put through\n `CostCurve` with `firstStep = 1`, rounded **UP**, once, at the end.\n- **Effective max level** = `MaxLevel` put through the character's\n `StatMaxLevelCurve` at its current rank (`firstStep = 1`), rounded up. This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** = `ValueCurve(BaseStatValue, N)`\n with **`firstStep = 0`**, then `RankCurve(thatValue, R)` with `firstStep = 1`, then the\n character-wide `RankStatCurve(R)`.\n ⚠ The stat level counts from **0** — a stat the player never upgraded is level 0 and is\n worth the plain base — while ranks, prices and item levels count from 1. Using the wrong\n first step shifts every value one step along the curve.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\nAn option's `Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterRankLadder (the rank ladder)\n\n```ts\ninterface CharacterRankLadder {\n MaxRank?: number; // how many ranks in total; empty = no ceiling\n FirstPaidRank?: number; // ranks below it are free; empty = 1\n PriceOptions?: Record<string, PriceOption>; // price of the first PAID rank\n CostCurve?: ScalarCurveSpec; // price growth; step = the rank, FirstPaidRank = first step\n}\n```\n\nLives on `CharacterDefinition.RankLadder`, or on the levels preset (`LevelsPreset.RankLadder`)\nwhen the character binds one. **It is the only source of rank prices** — there is no per-rank\ndictionary, and a character without a ladder cannot be ranked up at all.\n\nThe inline ladder **replaces** the preset one whole rather than merging field by field: it is one\nobject (\"how many ranks and how the price grows\"), and taking the ceiling from one place and the\nprice from another has no meaning.\n\n⚠ **A non-uniform ladder is expressed by the curve shape**, not by per-rank entries: `Table` and\n`TableAbsolute` place a multiplier per rank. What a ladder cannot express is a different price\nCOMPOSITION per rank (rank 5 paid in a special item, the rest in gold) — the composition is fixed\nby `PriceOptions` and the curve only scales the amount.\n\n⚠ **`FirstPaidRank` is how a free activation is expressed.** Level `0` means the character exists\nbut is not activated; in most titles the 0 → 1 step is free because the character itself is paid\nfor in `Unlock.PriceOptions`. Ranks below `FirstPaidRank` cost nothing and do **not** stop the\nclimb. The curve starts counting at `FirstPaidRank`, so the base is the price of the first rank\nthat actually costs something.\n\n`upgradeCharacterLevel` moves the character from its current level to the next one and charges the\nselected option of the ladder's `PriceOptions`, scaled by `CostCurve` at that rank.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the character's `RankStatCurve` at the current rank;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|