@idosgames/mcp 0.1.4 → 0.1.6
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 +28 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/character-system.json +2 -2
- package/registry/skills/checkout-system.json +6 -0
- package/registry/skills/cloud-code.json +2 -2
- package/registry/skills/collection-system.json +2 -2
- package/registry/skills/coop-event-system.json +2 -2
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/deal-offer-system.json +2 -2
- package/registry/skills/game-loop-system.json +1 -1
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/localization-system.json +6 -0
- package/registry/skills/lootbox-system.json +2 -2
- package/registry/skills/marketplace-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +2 -2
- package/registry/skills/quest-system.json +1 -1
- package/registry/skills/referral-system.json +1 -1
- package/registry/skills/season-system.json +1 -1
- package/registry/skills/social-system.json +1 -1
- package/registry/skills/store-system.json +2 -2
- package/registry/skills/timed-boost-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
- package/registry/skills/tutorial-system.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idosgames/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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.13\",\n \"@idosgames/core\": \"0.7.0\",\n \"@idosgames/module-sdk\": \"0.1.8\",\n \"@idosgames/react\": \"0.2.0\",\n \"@idosgames/wallet\": \"0.2.0\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"path": "src/config.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
"path": "src/LoginScreen.tsx",
|
|
30
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport 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, loginWithPlatformToken, 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\nexport interface LoginScreenExtras {\n /**\n * Wallet sign-in. Supplied by ./walletLogin (wired in main.tsx), which renders the ready-made\n * `WalletLogin` from `@idosgames/wallet/react`: connect → sign the challenge → session, then\n * `onAuthenticated()`. The screen passes its own `style` so the button matches the theme.\n *\n * Note: a wallet session is never restored silently (a fresh signature is required on every\n * launch), so keep at least one other provider for players who want to come straight back in.\n */\n renderWalletLogin?: (props: {\n client: LoginScreenProps[\"client\"];\n onAuthenticated: () => void;\n disabled: boolean;\n style?: CSSProperties;\n }) => ReactNode;\n}\n\ntype Mode = \"menu\" | \"email\";\n\n/** Minimal Google Identity surface — declared here so the template needs no @types/google.accounts. */\ntype GoogleIdentity = {\n accounts: {\n id: {\n initialize(config: {\n client_id: string;\n callback: (response: { credential?: string }) => void;\n }): void;\n prompt(): void;\n };\n };\n};\n\nexport function LoginScreen({\n client,\n onAuthenticated,\n renderWalletLogin,\n}: LoginScreenProps & LoginScreenExtras): ReactNode {\n const [mode, setMode] = useState<Mode>(\"menu\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [registering, setRegistering] = useState(false);\n\n const [remember, setRemember] = useState(true);\n\n /** Every provider goes through here, so one place owns the busy flag and the error surface. */\n const run = async (\n login: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n setError(null);\n // \"Remember me\" is read when the login completes, so set it before starting one. Off = this\n // session works normally but is not written to storage, so the next launch lands here again.\n client.auth.setRememberSession(remember);\n const result = await login();\n if (result.ok) {\n onAuthenticated();\n return;\n }\n setError(result.error ?? \"Sign-in failed. Please try again.\");\n setBusy(false);\n };\n\n const signInWithGoogle = (): void => {\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: ENV_GOOGLE_CLIENT_ID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: rgba(255, 255, 255, 0.65); }`}</style>\n <div style={styles.card}>\n <img src={LOGO_DATA_URL} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: { ...styles.button, ...styles.primary },\n })}\n\n {ENV_GOOGLE_CLIENT_ID && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={signInWithGoogle}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </button>\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n registering\n ? client.auth.registerWithEmail(email, password)\n : client.auth.loginWithEmail(email, password),\n )\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {/* Applies to every provider above. A wallet sign-in ignores it — those are never\n restored silently, a fresh signature is required on each launch. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? \"#fff\" : \"rgba(255, 255, 255, 0.3)\",\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n background: remember ? \"#0d66fe\" : \"#fff\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// Brand look: iDos Games blue with the white logo; controls are translucent white on top of it,\n// the primary action is solid white with blue text.\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"#0d66fe\",\n color: \"#fff\",\n font: \"14px system-ui, sans-serif\",\n },\n card: { width: \"min(340px, 88vw)\", display: \"grid\", gap: \"18px\" },\n logo: {\n width: \"180px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: \"rgba(255, 255, 255, 0.85)\",\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n transition: \"left 0.15s, background 0.15s\",\n },\n title: { margin: 0, fontSize: \"22px\", fontWeight: 600, textAlign: \"center\" },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.4)\",\n background: \"rgba(255, 255, 255, 0.14)\",\n color: \"inherit\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n primary: {\n background: \"#fff\",\n borderColor: \"#fff\",\n color: \"#0d66fe\",\n fontWeight: 600,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: \"rgba(255, 255, 255, 0.85)\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n input: {\n padding: \"11px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.35)\",\n background: \"rgba(255, 255, 255, 0.12)\",\n color: \"inherit\",\n font: \"inherit\",\n },\n error: { margin: 0, color: \"#ffd7d7\", textAlign: \"center\" },\n};\n"
|
|
30
|
+
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { beginSsoRedirect } from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"./env\";\nimport { LOGO_DATA_URL } from \"./logo\";\n\n// The Login scene. The host runtime owns WHEN this is shown (the auth gate in\n// @idosgames/app-shell); this file owns what it LOOKS like and which providers it offers.\n// Edit freely — branding, layout, copy, buttons.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus resetPassword.\n//\n// A provider is only rendered when it can actually complete, so players never meet a dead button:\n// guest / email — always available, no external setup.\n// Google — needs VITE_IDOS_GOOGLE_CLIENT_ID and the Google Identity script on the page.\n// wallet — always offered; ./walletLogin owns the wagmi config and the challenge network.\n// iDos Games — only where the platform accepts a return_to (see isSsoAvailable below).\n\n// Куда платформа соглашается вернуть одноразовый код. Список повторяет allowlist на бэкенде\n// (SsoService.AllowedOrigins) НАМЕРЕННО: здесь он решает только, показывать ли кнопку, а\n// настоящий барьер стоит на сервере. Показать кнопку там, где сервер откажет, — значит\n// пообещать игроку вход, который не состоится.\nconst SSO_ORIGINS = [\n \"https://cloud.idosgames.com\",\n \"https://idosgames.com\",\n \"https://www.idosgames.com\",\n];\n\nfunction isSsoAvailable(): boolean {\n return (\n typeof window !== \"undefined\" &&\n SSO_ORIGINS.includes(window.location.origin)\n );\n}\n\nexport interface LoginScreenExtras {\n /**\n * Wallet sign-in. Supplied by ./walletLogin (wired in main.tsx), which renders the ready-made\n * `WalletLogin` from `@idosgames/wallet/react`: connect → sign the challenge → session, then\n * `onAuthenticated()`. The screen passes its own `style` so the button matches the theme.\n *\n * Note: a wallet session is never restored silently (a fresh signature is required on every\n * launch), so keep at least one other provider for players who want to come straight back in.\n */\n renderWalletLogin?: (props: {\n client: LoginScreenProps[\"client\"];\n onAuthenticated: () => void;\n disabled: boolean;\n style?: CSSProperties;\n }) => ReactNode;\n}\n\ntype Mode = \"menu\" | \"email\";\n\n/** Minimal Google Identity surface — declared here so the template needs no @types/google.accounts. */\ntype GoogleIdentity = {\n accounts: {\n id: {\n initialize(config: {\n client_id: string;\n callback: (response: { credential?: string }) => void;\n }): void;\n prompt(): void;\n };\n };\n};\n\nexport function LoginScreen({\n client,\n onAuthenticated,\n renderWalletLogin,\n}: LoginScreenProps & LoginScreenExtras): ReactNode {\n const [mode, setMode] = useState<Mode>(\"menu\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [registering, setRegistering] = useState(false);\n\n const [remember, setRemember] = useState(true);\n\n /** Every provider goes through here, so one place owns the busy flag and the error surface. */\n const run = async (\n login: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n setError(null);\n // \"Remember me\" is read when the login completes, so set it before starting one. Off = this\n // session works normally but is not written to storage, so the next launch lands here again.\n client.auth.setRememberSession(remember);\n const result = await login();\n if (result.ok) {\n onAuthenticated();\n return;\n }\n setError(result.error ?? \"Sign-in failed. Please try again.\");\n setBusy(false);\n };\n\n const signInWithGoogle = (): void => {\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: ENV_GOOGLE_CLIENT_ID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: rgba(255, 255, 255, 0.65); }`}</style>\n <div style={styles.card}>\n <img src={LOGO_DATA_URL} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: { ...styles.button, ...styles.primary },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {isSsoAvailable() && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {ENV_GOOGLE_CLIENT_ID && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={signInWithGoogle}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </button>\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n registering\n ? client.auth.registerWithEmail(email, password)\n : client.auth.loginWithEmail(email, password),\n )\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {/* Applies to every provider above. A wallet sign-in ignores it — those are never\n restored silently, a fresh signature is required on each launch. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? \"#fff\" : \"rgba(255, 255, 255, 0.3)\",\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n background: remember ? \"#0d66fe\" : \"#fff\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// Brand look: iDos Games blue with the white logo; controls are translucent white on top of it,\n// the primary action is solid white with blue text.\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"#0d66fe\",\n color: \"#fff\",\n font: \"14px system-ui, sans-serif\",\n },\n card: { width: \"min(340px, 88vw)\", display: \"grid\", gap: \"18px\" },\n logo: {\n width: \"180px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: \"rgba(255, 255, 255, 0.85)\",\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n transition: \"left 0.15s, background 0.15s\",\n },\n title: { margin: 0, fontSize: \"22px\", fontWeight: 600, textAlign: \"center\" },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.4)\",\n background: \"rgba(255, 255, 255, 0.14)\",\n color: \"inherit\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n primary: {\n background: \"#fff\",\n borderColor: \"#fff\",\n color: \"#0d66fe\",\n fontWeight: 600,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: \"rgba(255, 255, 255, 0.85)\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n input: {\n padding: \"11px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.35)\",\n background: \"rgba(255, 255, 255, 0.12)\",\n color: \"inherit\",\n font: \"inherit\",\n },\n error: { margin: 0, color: \"#ffd7d7\", textAlign: \"center\" },\n};\n"
|
|
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": "f160bbae4de0d0e10b1b41d4f02a6c47ffd34fe3",
|
|
3
3
|
"runtimePackages": {
|
|
4
|
-
"@idosgames/core": "0.
|
|
5
|
-
"@idosgames/wallet": "0.
|
|
6
|
-
"@idosgames/module-sdk": "0.1.
|
|
7
|
-
"@idosgames/react": "0.
|
|
8
|
-
"@idosgames/app-shell": "0.1.
|
|
4
|
+
"@idosgames/core": "0.7.0",
|
|
5
|
+
"@idosgames/wallet": "0.2.0",
|
|
6
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
7
|
+
"@idosgames/react": "0.2.0",
|
|
8
|
+
"@idosgames/app-shell": "0.1.13"
|
|
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.
|
|
52
|
-
"@idosgames/wallet": "0.
|
|
49
|
+
"@idosgames/core": "0.7.0",
|
|
50
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
51
|
+
"@idosgames/react": "0.2.0",
|
|
52
|
+
"@idosgames/wallet": "0.2.0",
|
|
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.
|
|
98
|
-
"@idosgames/wallet": "0.
|
|
95
|
+
"@idosgames/core": "0.7.0",
|
|
96
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
97
|
+
"@idosgames/react": "0.2.0",
|
|
98
|
+
"@idosgames/wallet": "0.2.0",
|
|
99
99
|
"@tanstack/react-query": "5.101.2",
|
|
100
100
|
"phaser": "4.2.1",
|
|
101
101
|
"react": "19.2.7",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
"version": "0.1.0",
|
|
140
140
|
"dependencies": {
|
|
141
|
-
"@idosgames/module-sdk": "0.1.
|
|
141
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
142
142
|
"three": "0.185.1"
|
|
143
143
|
},
|
|
144
144
|
"fileCount": 38
|
|
@@ -157,9 +157,13 @@
|
|
|
157
157
|
"name": "character-system",
|
|
158
158
|
"description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly."
|
|
159
159
|
},
|
|
160
|
+
{
|
|
161
|
+
"name": "checkout-system",
|
|
162
|
+
"description": "Take payment for anything priced in a game on the iDosGames TypeScript SDK: price options (PriceOptions), real-money purchases through Google Play / App Store (client.purchase, PurchaseService), paying with an on-chain currency from the player's wallet (@idosgames/wallet payWithWalletEvm), and choosing what to show per platform (client.checkout, CheckoutService, usePayment). Use this whenever the user works in the iDosGames TS SDK and touches prices, IAP, receipts, store products, \"pay with crypto\", platform gating of payment methods, PriceOption, PaymentProof, SelectedOptionID, or asks why an option is missing or a purchase is refused — even if they don't name the module."
|
|
163
|
+
},
|
|
160
164
|
{
|
|
161
165
|
"name": "cloud-code",
|
|
162
|
-
"description": ""
|
|
166
|
+
"description": "Write and call custom server-side game logic on the iDosGames platform: author a CloudCode handler (sandboxed JavaScript with a server.* API) and invoke it from the game via client.cloudCode (CloudCodeService) with an arbitrary JSON payload. Use this whenever the user wants bespoke server logic, a \"cloud script\", \"cloud function\" or \"server callable\", logic that must be authoritative (granting rewards, validating a reported result, anti-cheat), a write to protected player data (UserCustomData ReadOnly / Internal buckets) or to shared title state (TitleCustomData Runtime scope), or otherwise touches client.cloudCode, CloudCodeService, handlers, server.SetUserCustomData, server.IncrementTitleCustomData, server.HttpRequest or ExecuteCloudCodeResponse — even if they don't name the module explicitly. Also covers integrating a title with a third-party service (calling an external API with a stored API key, webhooks out, payment or analytics providers) and the {{secret:NAME}} / {{var:NAME}} placeholders."
|
|
163
167
|
},
|
|
164
168
|
{
|
|
165
169
|
"name": "collection-system",
|
|
@@ -217,6 +221,10 @@
|
|
|
217
221
|
"name": "leaderboard-system",
|
|
218
222
|
"description": "Build a leaderboard / ranking system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.leaderboard (LeaderboardService): load leaderboard definitions, load a leaderboard's top list (+ batch), load the player's own progress (+ batch), submit a score (direct or from a trigger source, + batch), claim the cycle/instance-end rank reward (+ batch), claim a milestone reward (+ batch), claim every reward across many leaderboards in one call, fetch a combined top-list+progress overview (batch), and load a friends-filtered leaderboard view. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants ranking screens, PvP/score leaderboards, seasonal or cyclic ladders, rank-reward or milestone-reward UIs, or otherwise touches client.leaderboard, LeaderboardService, LeaderboardDefinitions, UserLeaderboardProgress, GetMyProgressResponse, or leaderboard cycles/brackets — even if they don't name the module explicitly."
|
|
219
223
|
},
|
|
224
|
+
{
|
|
225
|
+
"name": "localization-system",
|
|
226
|
+
"description": "Translate a game built on the iDosGames TypeScript SDK (@idosgames/core) via client.localization (LocalizationService): translate a key with t(), read the resolved locale, list the languages the title offers, switch the player's language, handle plurals and placeholders, and react to the localization:changed event. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants translations, multiple languages, i18n, a language picker, localized item/quest/store names, plural forms, or otherwise touches client.localization, LocalizationService, LocalizationState, LocalizationDefinitions, or t() — even if they don't name the module explicitly."
|
|
227
|
+
},
|
|
220
228
|
{
|
|
221
229
|
"name": "lootbox-system",
|
|
222
230
|
"description": "Build a lootbox / gacha / loot-crate system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load lootbox definitions (reward slots, weighted pools, price options, pity rules) and open one or many boxes for randomized rewards, including hard-pity tracking. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants loot crate / gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or bad-luck-protection systems, or otherwise touches client.lootbox, LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or UserLootboxState — even if they don't name the module explicitly."
|
|
@@ -277,6 +285,10 @@
|
|
|
277
285
|
"name": "title-system",
|
|
278
286
|
"description": "Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap config via client.title (TitleService): the full title public configuration bundle, server time, and the standalone currency/item definitions endpoints. Also documents the config-section registry (`client.data.config.getSection<T>(\"Section\")`) that every other module's skill depends on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app boot/init sequences or server time sync, or otherwise touches client.title, TitleService, TitlePublicConfigurationModel, getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions / GetItemDefinitions calls — even if they don't name the module explicitly."
|
|
279
287
|
},
|
|
288
|
+
{
|
|
289
|
+
"name": "tutorial-system",
|
|
290
|
+
"description": "Build an onboarding / tutorial system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.tutorial (TutorialService): load tutorial flow and step definitions, load the player's progress, start a flow, report a step as shown, complete or skip a step, skip a whole flow, claim the completion reward, and replay a flow. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a first-time user experience, onboarding, tutorial overlays, guided first session, coach marks, hint bubbles anchored to UI elements, a \"teach the player the board\" sequence, or otherwise touches client.tutorial, TutorialService, TutorialDefinitions, UserTutorialState, TutorialFlowView, or TutorialStepCompletionMode — even if they don't name the module explicitly."
|
|
291
|
+
},
|
|
280
292
|
{
|
|
281
293
|
"name": "user-custom-data",
|
|
282
294
|
"description": "Build a generic per-player key-value data store in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.userCustomData (UserCustomDataService): set/get/delete private (only-you-readable) and public (readable-by-others) string keys, batch set/delete many keys atomically, batch-read public data for many players at once, and load the title's schema-managed key registry. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants player settings/preferences storage, profile flair or badges visible to other players, arbitrary save-data slots, or otherwise touches client.userCustomData, UserCustomDataService, UserCustomDataModels, CustomDataBucket, or UserCustomDataRecord — even if they don't name the module explicitly."
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.
|
|
43
|
-
"@idosgames/wallet": "0.
|
|
40
|
+
"@idosgames/core": "0.7.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
42
|
+
"@idosgames/react": "0.2.0",
|
|
43
|
+
"@idosgames/wallet": "0.2.0",
|
|
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.
|
|
43
|
-
"@idosgames/wallet": "0.
|
|
40
|
+
"@idosgames/core": "0.7.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.8",
|
|
42
|
+
"@idosgames/react": "0.2.0",
|
|
43
|
+
"@idosgames/wallet": "0.2.0",
|
|
44
44
|
"@tanstack/react-query": "5.101.2",
|
|
45
45
|
"phaser": "4.2.1",
|
|
46
46
|
"react": "19.2.7",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "character-system",
|
|
3
3
|
"description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID)` | Buy/unlock a locked character (charges its `Unlock.Cost`). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.Cost and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has an `Unlock.Cost` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no `Cost` and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
|
|
4
|
+
"content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID, options?)` | Buy/unlock a locked character (charges the selected `Unlock.PriceOptions` option). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.PriceOptions and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\n// Third argument picks the way to pay and carries a store receipt when the option needs one.\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has `Unlock.PriceOptions` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no options and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\nWhen the character has several ways to pay, render them with\n`client.checkout.availableOptions(def.Unlock.PriceOptions)` and pass the chosen one:\n\n```ts\nawait client.character.unlockCharacter(\"Knight\", {\n selectedOptionID: option.OptionID,\n // required only when this option is paid in a store (a `Purchase` entry in its Cost)\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
|
|
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`, `CharacterLevelDefinition`, `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- [CharacterLevelDefinition (ranks)](#characterleveldefinition-ranks)\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<\n string,\n { Levels?: Record<string, CharacterLevelDefinition> }\n >;\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 Cost?: ResourceConsume; // null/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 Levels?: Record<string, CharacterLevelDefinition>; // \"1\",\"2\",... -> rank\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** `Cost` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `Cost` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\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 BaseCostResource: ResourceConsume; // cost of level 1 (must be non-empty)\n CostScalingFactor: number; // linear cost growth per level\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n StatScalingFactor: number; // flat value added per stat level (additive, not a %)\n CharacterLevelScalingFactor: number; // fractional per-rank value growth (multiplicative)\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nFormulas (the backend applies these; use them for previews):\n\n- **Cost of level `N`** (N = target level, 1-based): each entry's base `Amount`\n scaled by `Amount * (1 + CostScalingFactor * (N - 1))`, rounded to the nearest\n whole amount.\n- **Effective max level** = `floor(MaxLevel * StatMaxLevelMultiplier)` where the\n multiplier comes from the character's _current_ rank\n (`CharacterLevelDefinition.StatMaxLevelMultiplier`, default 1). 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`** =\n `(BaseStatValue + N * StatScalingFactor) * (1 + CharacterLevelScalingFactor * (R - 1))`.\n The stat part grows **additively** (`N = 0` before the first upgrade, so the\n base value is what a fresh character has); the rank part multiplicatively.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\n`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## CharacterLevelDefinition (ranks)\n\nConfig for one character Level/rank. Key in `Levels` is the level number as a\nstring (`\"1\"`, `\"2\"`, ...). Level `0` = uninitialized, has no config.\n\n```ts\ninterface CharacterLevelDefinition {\n Level: number;\n UpgradeCost?: ResourceConsume; // cost to reach this level; absent = free\n GlobalStatMultiplier?: number; // multiplies the character's stat values (and thus Power) at this rank (1.0 = none)\n StatMaxLevelMultiplier?: number; // raises every stat's cap at this rank\n AssetPaths?: Record<string, string>;\n}\n```\n\n`upgradeCharacterLevel` moves the character from its current level to the next\none and charges that level's `UpgradeCost`.\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 rank's `GlobalStatMultiplier`;\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`, `CharacterLevelDefinition`, `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- [CharacterLevelDefinition (ranks)](#characterleveldefinition-ranks)\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<\n string,\n { Levels?: Record<string, CharacterLevelDefinition> }\n >;\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 Levels?: Record<string, CharacterLevelDefinition>; // \"1\",\"2\",... -> rank\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 CostScalingFactor: number; // linear cost growth per level\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n StatScalingFactor: number; // flat value added per stat level (additive, not a %)\n CharacterLevelScalingFactor: number; // fractional per-rank value growth (multiplicative)\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nFormulas (the backend applies these; use them for previews):\n\n- **Cost of level `N`** (N = target level, 1-based): each entry's base `Amount`\n scaled by `Amount * (1 + CostScalingFactor * (N - 1))`, rounded to the nearest\n whole amount.\n- **Effective max level** = `floor(MaxLevel * StatMaxLevelMultiplier)` where the\n multiplier comes from the character's _current_ rank\n (`CharacterLevelDefinition.StatMaxLevelMultiplier`, default 1). 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`** =\n `(BaseStatValue + N * StatScalingFactor) * (1 + CharacterLevelScalingFactor * (R - 1))`.\n The stat part grows **additively** (`N = 0` before the first upgrade, so the\n base value is what a fresh character has); the rank part multiplicatively.\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## CharacterLevelDefinition (ranks)\n\nConfig for one character Level/rank. Key in `Levels` is the level number as a\nstring (`\"1\"`, `\"2\"`, ...). Level `0` = uninitialized, has no config.\n\n```ts\ninterface CharacterLevelDefinition {\n Level: number;\n PriceOptions?: Record<string, PriceOption>; // ways to pay for this level; empty/absent = free\n GlobalStatMultiplier?: number; // multiplies the character's stat values (and thus Power) at this rank (1.0 = none)\n StatMaxLevelMultiplier?: number; // raises every stat's cap at this rank\n AssetPaths?: Record<string, string>;\n}\n```\n\n`upgradeCharacterLevel` moves the character from its current level to the next\none and charges the selected option of that level's `PriceOptions`.\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 rank's `GlobalStatMultiplier`;\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
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "checkout-system",
|
|
3
|
+
"description": "Take payment for anything priced in a game on the iDosGames TypeScript SDK: price options (PriceOptions), real-money purchases through Google Play / App Store (client.purchase, PurchaseService), paying with an on-chain currency from the player's wallet (@idosgames/wallet payWithWalletEvm), and choosing what to show per platform (client.checkout, CheckoutService, usePayment). Use this whenever the user works in the iDosGames TS SDK and touches prices, IAP, receipts, store products, \"pay with crypto\", platform gating of payment methods, PriceOption, PaymentProof, SelectedOptionID, or asks why an option is missing or a purchase is refused — even if they don't name the module.",
|
|
4
|
+
"content": "---\nname: checkout-system\ndescription: >-\n Take payment for anything priced in a game on the iDosGames TypeScript SDK:\n price options (PriceOptions), real-money purchases through Google Play /\n App Store (client.purchase, PurchaseService), paying with an on-chain\n currency from the player's wallet (@idosgames/wallet payWithWalletEvm), and\n choosing what to show per platform (client.checkout, CheckoutService,\n usePayment). Use this whenever the user works in the iDosGames TS SDK and\n touches prices, IAP, receipts, store products, \"pay with crypto\", platform\n gating of payment methods, PriceOption, PaymentProof, SelectedOptionID, or\n asks why an option is missing or a purchase is refused — even if they don't\n name the module.\n---\n\n# Checkout (iDosGames TS SDK)\n\nEverything a title sells is priced the same way: a **`PriceOptions` dictionary**\non the entity, where each option is one way to pay. The key of the dictionary is\nthe option's `OptionID`, and the player picks one.\n\nOptions exist because a single cost is charged **as a whole** — that is \"and\".\nA price across platforms is a **choice** — \"or\": a store SKU on mobile, a token\non web. One option, one way to pay.\n\n```ts\n// An offer with two ways to pay.\nPriceOptions: {\n gold: { OptionID: \"gold\", Cost: { Standard: { Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"gold\", Amount: 500 }] } } },\n store: { OptionID: \"store\", Cost: { Standard: { Entries: [{ Type: \"Purchase\", ProductID: \"coin_pack_small\" }] } },\n AllowedPlatforms: [\"Android\", \"Ios\"] },\n}\n```\n\n## The one rule that matters\n\n**The client renders, the server allows.**\n\nTitle config ships as one immutable artifact per (version, field set), so it\n_cannot_ be filtered per request — every platform receives the same price list.\nSo the client hides what it may not offer, and the server refuses to charge an\noption that is not available on the caller's platform. Showing an option a store\nforbids is a policy problem; paying with it is impossible either way.\n\nThat is why you never hand-roll the filter:\n\n```ts\nconst options = client.checkout.availableOptions(offer.PriceOptions);\n```\n\n## Paying\n\nWhich of the three shapes you are in is answered by one call:\n\n```ts\nconst requirement = client.checkout.requirementOf(option);\n// { kind: \"store\", productID } → buy the SKU, then pass the receipt\n// { kind: \"resources\" } → just call the module\n// { kind: \"resources\", shortfall } → crypto price, player is short\n```\n\n### 1. Resources (currency, items, tokens)\n\nNothing special — call the module that owns the entity:\n\n```ts\nawait client.store.purchase(offerID, count); // SelectedOptionID defaults to the first available\n```\n\nPass `SelectedOptionID` explicitly whenever the player chose an option — the\ndefault only exists so a single-price entity keeps working without any client\nchange.\n\n### 2. Real money (Google Play / App Store)\n\nThe receipt comes from the store SDK on the device; the SDK never talks to a\nstore itself. Attach it to the purchase:\n\n```ts\nawait client.store.purchase(offerID, count, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n⚠ **The money is already gone when the server first hears about it.** A refusal\nhere is an incident, not \"insufficient funds\" — the server records every refusal\nin the title's transaction ledger together with what the receipt was paying for.\nTwo consequences for your code:\n\n- **Never drop a receipt.** Hand every receipt the store gives you to the server,\n including the unfinished ones it re-delivers on startup. An order confirmed to\n the store but never delivered to us is a purchase the player paid for and will\n never receive.\n- **Re-submitting the same receipt is safe** and grants nothing twice — but the\n response then carries no resources. Apply `Resources` only when the server says\n it granted something (`Granted: true` for `client.purchase`, a non-empty\n `Resources` for module purchases).\n\nFor products that ARE the goods (gem packs, remove-ads, subscriptions) use\n`client.purchase` directly — `validatePurchase`, `validatePurchasesBatch` for\nrestore after a reinstall.\n\n### 3. On-chain currency from the player's wallet\n\nThe price is denominated in tokens, and the player pays from their in-game\nbalance. When that balance is short, top it up from their own wallet — this\nlives in `@idosgames/wallet`, because `core` never imports a wallet stack:\n\n```ts\nimport { payWithWalletEvm } from \"@idosgames/wallet\";\n\nconst { shortfall } = client.checkout.requirementOf(option);\nif (shortfall) {\n await payWithWalletEvm({\n client,\n clients,\n network,\n tokenAddress,\n decimals,\n titleID,\n shortfall,\n completePurchase: () =>\n client.store.purchase(offerID, count, {\n selectedOptionID: option.OptionID,\n }),\n });\n}\n```\n\n### Host owns the session (Unity WebGL, native shells)\n\nWhen the wallet lives on the page but the game's session does not — a Unity WebGL build is the\ncase — use `depositTokenOnChainEvm` instead. It does only the on-chain half (approve → deposit →\nwait for the receipt) and reports nothing to the backend; the host registers the tx hash with its\nown session and then makes the purchase. Handing the page a logged-in client instead would give\nevery script on it full access to the player's account.\n\n⚠ **This is two steps and cannot be one.** A deposit confirms on-chain, so the\ntokens land on the balance in one request and the purchase happens in the next.\nNothing is lost if the game dies in between — the tokens are credited — but the\nplayer is left with money and no goods unless you can resume. **Keep the intent\nlocally and offer to finish it on the next launch.**\n\nOnly the **shortfall** is deposited, not the whole price: whatever the player\nalready holds in-game is spent first.\n\n## React\n\n`usePayment` owns the part every payment UI repeats — which options to show,\nwhat each needs, and which stage the attempt is at:\n\n```tsx\nconst { options, requirementOf, stage, error, pay } = usePayment(\n offer.PriceOptions,\n);\n```\n\n`stage` is a value (`awaiting-store`, `awaiting-wallet`, `purchasing`, …) rather\nthan a boolean on purpose: a wallet payment takes tens of seconds while the\ndeposit confirms, and a dialog showing only \"loading\" makes a working payment\nlook hung — so the player taps again.\n\n## Where a price may NOT be paid in a store\n\nSome prices are resource-only, and the server says so explicitly rather than\nfailing deep inside the resource engine:\n\n| Where | Why |\n| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| Item / character / stat upgrades | Price grows by a formula per level; a store SKU is a fixed tier and cannot follow it |\n| Craft | Price is per craft and multiplied by the count — there is no \"one and a half receipts\" |\n| Coop-event spins | Only the spins that actually happened are charged; a store cannot refund half a receipt |\n| Match creation fee, marketplace listing fee | May have to be refunded, and a store refund is not ours to issue — plus these are player-to-player contexts |\n| AI generation | A rate-limited micro-action; one receipt cannot be split across many of them |\n\nDeal offers, store offers, lootboxes, collection packs, character unlocks and\ntimed boosts **do** accept store payment — that is the main monetisation path.\n\n## Common mistakes\n\n- **Filtering options yourself by `AllowedPlatforms`.** Use\n `client.checkout.availableOptions` — it also applies the same ordering the\n server uses when the player did not choose an option.\n- **Trusting `PriceUsdCents` for display on mobile.** Show the store's own\n localized price; the declared one is for analytics and sorting, and both\n platforms require their price to be the one displayed.\n- **Applying `Resources` from a repeated receipt.** See above — that credits the\n reward twice for a single payment.\n- **Assuming an option exists because the config has it.** On a platform where it\n is not allowed, the server refuses the charge; render from\n `availableOptions`, not from the raw dictionary.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloud-code",
|
|
3
|
-
"description": "",
|
|
4
|
-
"content": "---\r\nname: cloud-code\r\ndescription: >-\r\n Write and call custom server-side game logic on the iDosGames platform:\r\n author a CloudCode handler (sandboxed JavaScript with a server.* API) and\r\n invoke it from the game via client.cloudCode (CloudCodeService) with an\r\n arbitrary JSON payload. Use this whenever the user wants bespoke server\r\n logic, a \"cloud script\", \"cloud function\" or \"server callable\", logic that\r\n must be authoritative (granting rewards, validating a reported result,\r\n anti-cheat), a write to protected player data (UserCustomData ReadOnly /\r\n Internal buckets) or to shared title state (TitleCustomData Runtime scope),\r\n or otherwise touches client.cloudCode, CloudCodeService, handlers,\r\n server.SetUserCustomData, server.IncrementTitleCustomData,\r\n server.HttpRequest or ExecuteCloudCodeResponse — even if they don't name the\r\n module explicitly. Also covers integrating a title with a third-party service\r\n (calling an external API with a stored API key, webhooks out, payment or\r\n analytics providers) and the {{secret:NAME}} / {{var:NAME}} placeholders.\r\n---\r\n\r\n# Cloud Code (iDosGames TS SDK)\r\n\r\nCloud Code runs **your** JavaScript on the platform's servers. A script defines\r\nnamed handlers; the game calls one by name and gets back whatever JSON it\r\nreturns.\r\n\r\nTwo distinct reasons to reach for it:\r\n\r\n1. **Authority.** Client code that \"grants\" a reward, \"validates\" a score or\r\n \"unlocks\" a level is a suggestion — the player owns the browser. Logic whose\r\n outcome must be trusted belongs in a handler.\r\n2. **Protected data.** The `ReadOnly`/`Internal` buckets of a player's\r\n UserCustomData and the `Runtime` scope of the title's data store have no\r\n client write path at all. A handler is the only way to write them.\r\n\r\nIf a dedicated module already covers what you need (currency, item, store,\r\nquest, character, leaderboard…), prefer that module — it gives you typed\r\nrequest/response shapes and cache integration; Cloud Code gives you neither.\r\n\r\nPublishing a script is a platform operation, not an SDK one: the AI Coder does\r\nit with its `SaveCloudCode` tool, an external agent with the backend MCP's\r\n`publish_cloud_code`, and a publisher from the dashboard. Publishing **replaces\r\nthe whole revision** — read the current source first (`GetCloudCode` with\r\n`include_code`, or `get_cloud_code`) and send it back with your handler added,\r\nor you silently delete every handler the game still calls.\r\n\r\n## Mental model\r\n\r\nThere is exactly one client-facing action: `execute`. You pass a **function\r\nname** (a handler defined in the title's deployed script's `handlers` object)\r\nand an optional **arbitrary JSON payload**; the server runs it in a sandboxed\r\nJS engine and returns an arbitrary JSON result plus execution metadata (logs,\r\ntiming, error info). The SDK has no idea what a given script's args or result\r\nlook like — **you** know your title's script contract, so you type the\r\npayload and result yourself (see Gotchas). There's no \"config vs state\" split\r\nhere like other modules — Cloud Code has no persistent per-player data model\r\nof its own; it's pure request/response.\r\n\r\nScript failure is a **first-class outcome, not a network error**: if the\r\nscript throws, times out, is rate-limited, or the handler doesn't exist, the\r\ncall still comes back `{ ok: true, data }` with `data.Error` populated and\r\n`data.FunctionResult` empty. Only infrastructure problems (not logged in, bad\r\nlocal args, connection issues, backend down) surface as `{ ok: false }`.\r\n\r\n## Writing a handler\r\n\r\nA revision is one plain JavaScript file that fills the global `handlers` object.\r\nNo imports, no modules, no `async`/`await`, no `fetch` — everything you can touch\r\nis on `server.*` and `log.*`, and every call is synchronous. Outbound network\r\naccess exists but only through `server.HttpRequest`, and only to hosts the\r\npublisher allow-listed (see _Calling another service_).\r\n\r\n```js\r\nhandlers.claimDailyBonus = function (args, context) {\r\n // context: { UserID, FunctionName, Revision, InvokedAt }\r\n var data = server.GetUserCustomData();\r\n if (!data.Success) throw new Error(data.Error);\r\n\r\n var last = data.Data.ReadOnly[\"daily_claimed_at\"];\r\n var today = new Date().toISOString().slice(0, 10);\r\n if (last && last.Value === today)\r\n return { granted: false, reason: \"already_claimed\" };\r\n\r\n var write = server.SetUserCustomData(\"ReadOnly\", \"daily_claimed_at\", today);\r\n if (!write.Success) throw new Error(write.Error);\r\n\r\n server.IncrementTitleCustomData(\"Public\", \"daily_claims_total\", 1);\r\n log.Info(\"daily bonus granted\", { user: context.UserID });\r\n return { granted: true };\r\n};\r\n```\r\n\r\n### The `server.*` API\r\n\r\nEvery call returns `{ Success, Error, Data }` — **check `Success`**; a rejected\r\nwrite (limit hit, wrong bucket, version conflict) is a normal result, not a\r\nthrow. Each call also counts against the per-execution API budget, so batch.\r\n\r\n| Call | What it does |\r\n| ----------------------------------------------------------------- | ------------------------------------------------------------------ |\r\n| `server.ReadUserData([\"InventoryV2\", \"Premium\", …])` | Read whitelisted sections of the caller's player document. |\r\n| `server.GetTitleConfig(\"Currency\", \"Item\", …)` | Read the title's configuration sections. |\r\n| `server.GetUserCustomData()` | All four buckets of the caller, **including `Internal`**. |\r\n| `server.GetPublicUserCustomDataOf(userId)` | Another player's `Public` bucket. |\r\n| `server.SetUserCustomData(bucket, key, value)` | Write any bucket — this is the protected-data write. |\r\n| `server.DeleteUserCustomData(bucket, key)` | Delete a key from any bucket (idempotent). |\r\n| `server.BatchSetUserCustomData([{Bucket, KeyID, Value}, …])` | Atomic multi-key write (all-or-nothing). |\r\n| `server.BatchDeleteUserCustomData([{Bucket, KeyID}, …])` | Atomic multi-key delete. |\r\n| `server.GetTitleCustomData()` | Title store: both scopes, both buckets. |\r\n| `server.SetTitleCustomData(bucket, key, value, expectedVersion?)` | Write the title's `Runtime` scope; pass a version for CAS. |\r\n| `server.IncrementTitleCustomData(bucket, key, delta)` | Atomic counter on shared data — use this, never read-modify-write. |\r\n| `server.DeleteTitleCustomData(bucket, key)` | Delete a `Runtime` key. |\r\n| `server.BatchSetTitleCustomData` / `BatchDeleteTitleCustomData` | Atomic multi-key variants for the title store. |\r\n| `server.GetIntegrationVariable(name)` | Read a non-secret integration setting (base URL, account id). |\r\n| `server.HttpRequest({ Method, Url, Headers, Body, ContentType })` | Call an external API — the only way out of the sandbox. |\r\n| `server.AddQuestProgress(metricID, value)` | Advance quest objectives configured with `Source: \"ServerApi\"`. |\r\n\r\n`server.AddQuestProgress` is the only way to move a `ServerApi` objective —\r\nneither the client nor the dashboard can touch those. Use it when only the server\r\nknows the fact (anti-cheat verdict, match result, an external system confirming\r\nvia `server.HttpRequest`). Unlike the client's `addQuestProgress` it neither bans\r\nnor clamps on `MaxProgressPerCall`: the script is written by the title owner, so\r\nthe value is trusted. It still clamps to the objective's `TargetValue`. Quests\r\nwhose objectives use `ClientApi` or `SystemEvent` are unreachable from here.\r\n\r\n`log.Debug/Info/Warning/Error(message, data?)` records a line the publisher sees\r\n(and, if the title reveals logs, the client too). It costs no API budget.\r\n\r\nNotes that bite:\r\n\r\n- Bucket and scope names are **case-sensitive strings**: `\"Private\"`,\r\n `\"Public\"`, `\"ReadOnly\"`, `\"Internal\"` for player data; `\"Public\"`,\r\n `\"Private\"` for title data. Anything else comes back as an error result.\r\n- Title writes always land in the `Runtime` scope — the `Static` scope is\r\n authored configuration and a script cannot touch it.\r\n- Shared counters must go through `IncrementTitleCustomData` (or\r\n `SetTitleCustomData` with `expectedVersion` from the record you read).\r\n Read-then-write from two concurrent calls silently loses one of them.\r\n- `throw` inside a handler is fine — it reaches the caller as a script-level\r\n error with your message, which is usually what you want for \"not allowed\".\r\n\r\n### Calling another service\r\n\r\nA handler can call a third-party API. The credential never appears in your code:\r\nyou reference it by placeholder and the platform substitutes it after your script\r\nhas run, immediately before the request leaves.\r\n\r\n```js\r\nhandlers.notifyDiscord = function (args, context) {\r\n var res = server.HttpRequest({\r\n Method: \"POST\",\r\n Url: \"https://discord.com/api/webhooks/{{var:DISCORD_WEBHOOK_PATH}}\",\r\n Headers: { Authorization: \"Bearer {{secret:DISCORD_TOKEN}}\" },\r\n Body: JSON.stringify({ content: \"Player \" + context.UserID + \" won!\" }),\r\n });\r\n if (!res.Success) throw new Error(res.Error); // network/policy failure\r\n if (!res.Data.Ok) return { sent: false, status: res.Data.Status };\r\n return { sent: true };\r\n};\r\n```\r\n\r\n- `{{secret:NAME}}` — an API key or token. **You can never read its value**, in\r\n any tool or any call; there is no `GetSecret`. That is deliberate: a value in\r\n JS could be returned to the player or logged by accident.\r\n- `{{var:NAME}}` — a non-secret setting. Also readable with\r\n `server.GetIntegrationVariable(name)` when you need it as a value.\r\n- `res.Data` is `{ Status, Ok, Body, BodyTooLarge, ContentType }`. `Body` is a\r\n string — parse it yourself; anything matching a substituted secret is replaced\r\n with `***` before you see it.\r\n\r\nWhat the platform enforces, and what you cannot work around from a script:\r\n\r\n- **Only allow-listed hosts.** The publisher lists them per title; there is no\r\n allow-all. An unlisted host fails with a clear message — surface it rather than\r\n retrying.\r\n- **https only** (unless the title explicitly allows plain http), **no\r\n redirects**, and no requests to private/loopback addresses.\r\n- **Per-execution request cap** (3 by default) and a **response size cap** — an\r\n oversized body is dropped, not truncated, with `BodyTooLarge: true`.\r\n- The whole call still lives inside the 10-second execution budget, so one slow\r\n integration can starve everything after it.\r\n\r\nIf the credential or the host you need does not exist yet, say exactly what has\r\nto be added in the title's **Integrations** settings — you cannot add either one.\r\n\r\n### Limits you are designing against\r\n\r\n10 seconds of wall-clock per call (hard, whatever the title configures), a cap\r\non statements and recursion depth, a cap on `server.*` calls per execution, and\r\nbyte ceilings on the returned result and the logs. Handlers are short decisions,\r\nnot jobs.\r\n\r\n## Setup\r\n\r\n```ts\r\nimport { createIDosGamesClient } from \"@idosgames/core\";\r\n\r\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\r\nawait client.auth.loginWithDeviceID(); // or any auth.* method\r\n\r\nconst cloudCode = client.cloudCode; // the CloudCodeService\r\n```\r\n\r\nRequires an authenticated session — without one, `execute` returns\r\n`{ ok: false, reason: \"unauthorized\" }` rather than making a request.\r\n\r\n## Methods\r\n\r\n`execute` returns `Promise<OperationResult<ExecuteCloudCodeResponse>>`: either\r\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\r\n`result.ok` before touching `result.data` — and then check `data.Error` before\r\ntrusting `data.FunctionResult` (see below). `reason` is one of `\"client\"`\r\n(empty/whitespace-only function name), `\"unauthorized\"`, `\"throttled\"` (fired\r\nthe same call again inside the throttle window), `\"connection\"` (transient,\r\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"`\r\n(infrastructure-level rejection — `error` carries the human-readable reason).\r\n\r\n| Method | Purpose | `data` on success |\r\n| ---------------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------- |\r\n| `execute(functionName, functionParameter?, revisionSelection?, specificRevision?)` | Run a title-defined cloud script handler by name. | `ExecuteCloudCodeResponse` |\r\n\r\nParameters:\r\n\r\n- `functionName` — the handler name inside the deployed script's `handlers`\r\n object. Case-sensitive; trimmed before sending. Client-side, only\r\n empty/whitespace is rejected (`reason: \"client\"`). Server-side, the backend\r\n additionally rejects (as a script-level `InvalidFieldName` error, not an\r\n `OperationResult` failure) names containing `.`, `$`, whitespace, or control\r\n characters, or longer than 128 characters — these are illegal as MongoDB\r\n field names since the name can end up in audit/log paths.\r\n- `functionParameter?` — any `JsonValue` (object, array, string, number,\r\n boolean, or null) passed as the handler's first argument. Omit if the script\r\n needs no input. If it's an object (at any nesting depth), none of its keys\r\n may contain `.` or `$` — the backend rejects such payloads with a\r\n script-level `InvalidFieldName` error before the script ever runs.\r\n- `revisionSelection?` — `\"Live\"` (default when omitted), `\"Latest\"`, or\r\n `\"Specific\"`. Lets you target a non-live revision for testing.\r\n- `specificRevision?` — the revision number to run; only used when\r\n `revisionSelection` is `\"Specific\"`.\r\n\r\n`ExecuteCloudCodeResponse` shape:\r\n\r\n| Field | Meaning |\r\n| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |\r\n| `FunctionName` | Echo of the handler that ran. |\r\n| `Revision` | Which revision actually executed. |\r\n| `FunctionResult` | The script's return value — arbitrary JSON, `null` if it returned nothing or on error. |\r\n| `FunctionResultTooLarge` | `true` if the result was dropped for exceeding the title's result-size limit (`FunctionResult` is `null` in that case). |\r\n| `Logs` | Array of `{ Level, Message?, Data? }` entries from `log.debug/info/warn/error` calls inside the script. |\r\n| `LogsTooLarge` | `true` if logs were truncated for exceeding the title's log-size limit. |\r\n| `ExecutionTimeSeconds` | Server-side wall-clock execution duration. |\r\n| `APIRequestsIssued` | Count of server API calls the script made internally (e.g. reading user data) — counts toward a per-execution cap. |\r\n| `Error` | `{ Error: CloudCodeErrorCode, Message?, StackTrace? }`, present only when the script failed or never ran; `null`/absent on success. |\r\n\r\n`CloudCodeErrorCode` values: `None`, `Disabled`, `NoActiveRevision`,\r\n`RevisionNotFound`, `InvalidFieldName`, `RateLimited`, `HandlerNotFound`,\r\n`HandlerDisabled`, `Timeout`, `StatementCountExceeded`, `StackOverflow`,\r\n`ApiCallLimitExceeded`, `JavaScriptException`, `ExecutionError` — stable, safe\r\nto switch on for retry/UX logic (e.g. treat `RateLimited`/`Timeout` as\r\nretryable, others as not).\r\n\r\nOn success, the SDK emits an event — it does **not** write anything into\r\n`client.data`, since the result shape is script-specific and there's no\r\ngeneric cache slot for it. If your script mutates player state (grants\r\ncurrency, items, etc. via server-side APIs), re-fetch that state through its\r\nowning module afterward — Cloud Code itself won't refresh your local cache.\r\n\r\n## Events\r\n\r\nSubscribe with `client.on(...)`; returns an unsubscribe fn.\r\n\r\n- `cloudCode:executed` → `ExecuteCloudCodeResponse` — fired whenever `execute` returns `{ ok: true }`, regardless of whether the script itself succeeded (check `data.Error` inside the handler).\r\n\r\n```ts\r\nconst off = client.on(\"cloudCode:executed\", (r) => {\r\n if (r.Error) console.warn(\"script failed:\", r.Error.Error, r.Error.Message);\r\n});\r\n// later: off();\r\n```\r\n\r\n## Recipes\r\n\r\n### Call a script and handle both failure layers\r\n\r\n```ts\r\ninterface GrantBonusArgs {\r\n reason: string;\r\n}\r\ninterface GrantBonusResult {\r\n granted: number;\r\n}\r\n\r\nconst args: GrantBonusArgs = { reason: \"daily\" };\r\nconst result = await client.cloudCode.execute(\"grantLoginBonus\", args);\r\nif (!result.ok) return showError(result.error ?? result.reason); // infra-level failure\r\n\r\nif (result.data.Error) {\r\n return showError(result.data.Error.Message ?? result.data.Error.Error); // script-level failure\r\n}\r\n\r\nconst payload = result.data.FunctionResult as GrantBonusResult; // your contract — cast/validate it yourself\r\nconsole.log(`granted ${payload.granted}`);\r\n```\r\n\r\n### Fire-and-forget script with no input\r\n\r\n```ts\r\nconst result = await client.cloudCode.execute(\"resetDailyQuests\");\r\nif (!result.ok || result.data.Error) {\r\n console.warn(\"resetDailyQuests failed\", result.error ?? result.data.Error);\r\n}\r\n```\r\n\r\n### Test against a specific revision before it goes live\r\n\r\n```ts\r\nconst result = await client.cloudCode.execute(\r\n \"computeMatchReward\",\r\n { matchID },\r\n \"Specific\",\r\n 42, // revision number\r\n);\r\n```\r\n\r\n### Surface script logs during development\r\n\r\n```ts\r\nconst result = await client.cloudCode.execute(\"debugScript\", { x: 1 });\r\nif (result.ok) {\r\n for (const log of result.data.Logs ?? []) {\r\n console.log(`[${log.Level}]`, log.Message, log.Data);\r\n }\r\n}\r\n```\r\n\r\nLogs only come back at all if the title has logs enabled for clients; on\r\ntitles that don't, `Logs` is always an empty array even though the script did\r\nlog server-side — don't treat an empty array as proof the script logged\r\nnothing.\r\n\r\n### Chain a cloud-code call with a resource refresh\r\n\r\n```ts\r\nconst res = await client.cloudCode.execute(\"craftSpecialItem\", { recipeID });\r\nif (!res.ok || res.data.Error) return showError(res.error ?? res.data.Error);\r\n\r\n// The script granted items/currency server-side — Cloud Code didn't touch the\r\n// cache, so pull the owning module's state to see the new balance/inventory.\r\nawait client.user.getClientState(); // or the specific module's getter, e.g. client.item...\r\n```\r\n\r\n## Gotchas\r\n\r\n- **Two failure layers, don't conflate them.** `result.ok === false` means the\r\n call itself failed (auth, bad args, connection) — the script never ran or\r\n its outcome is unknown. `result.ok === true && result.data.Error` means the\r\n call succeeded but the _script_ failed (threw, timed out, disabled,\r\n unknown/undeclared handler, rate-limited) — always check both before\r\n trusting `FunctionResult`.\r\n- **Unknown handler is a script-level error, not a client-side check.** The\r\n SDK never validates that `functionName` refers to a real handler — that's\r\n entirely server-side. Depending on the title's config you can get\r\n `HandlerNotFound` either because the name isn't in the title's declared\r\n handler whitelist, or because the deployed script simply never defined\r\n `handlers[functionName]`; both look the same to the caller. A handler can\r\n also be individually killed by an admin, which comes back as\r\n `HandlerDisabled`.\r\n- **A hard 10-second ceiling always applies.** Whatever timeout the title/\r\n revision configures, the backend clamps every single execution to a 10\r\n second wall-clock budget; past that you get `Timeout` no matter what. Don't\r\n design a script-based feature around long-running work.\r\n- **Rate limiting can hit independently of the generic per-endpoint throttle.**\r\n Beyond the SDK's own ~600ms client-side throttle per call and the\r\n transport's per-user rate limit, the title can configure CloudCode-specific\r\n limits at three levels — whole title, this user, or this user+handler pair.\r\n Any of them tripping comes back as `data.Error.Error === \"RateLimited\"`\r\n (an in-band script-level outcome, `result.ok` is still `true`), with\r\n `data.Error.Message` naming which layer triggered it — treat it as\r\n retryable-after-a-delay, not a hard failure.\r\n- **No client-side validation of script logic.** The SDK only validates that\r\n `functionName` is non-empty and that you're logged in. Argument shape,\r\n business rules, and error handling are entirely up to the script — a\r\n malformed `functionParameter` will fail server-side (`JavaScriptException`\r\n or similar), not client-side.\r\n- **Type the payload and result yourself.** `functionParameter` is `JsonValue`\r\n and `FunctionResult` is `JsonValue | null` — the SDK has no schema for your\r\n title's specific scripts. Define your own request/response interfaces per\r\n handler (as in the recipes above) and cast/validate after the call.\r\n- **Cloud Code doesn't touch `client.data`.** Unlike feature modules, a\r\n successful `execute` doesn't mirror anything into the cache. If the script\r\n changed player-facing state, re-fetch it via the owning module (e.g. call\r\n the Economy/Item/Character module's getter) so the UI reflects it.\r\n- **`Logs`/`FunctionResult` can be silently dropped.** Both are subject to a\r\n title-configured byte-size ceiling; check `LogsTooLarge` /\r\n `FunctionResultTooLarge` before assuming absence means the script produced\r\n nothing. Whether `Logs` is populated at all (even under the size limit) also\r\n depends on a title setting — some titles never reveal script logs to\r\n clients.\r\n- **Keys in your JSON payload can't contain `.` or `$`.** This is a MongoDB\r\n field-name restriction the backend enforces recursively on\r\n `functionParameter` (and on whatever the script returns) — a payload with a\r\n dotted or `$`-prefixed key fails with `InvalidFieldName` before the script\r\n even starts. Stick to plain alphanumeric/underscore keys.\r\n- **Never put a third-party key in game code.** The project ships to the\r\n player's browser; a key there is a public key. The call belongs in a handler,\r\n and the key belongs in the title's integration store.\r\n- **Treat an integration's response as untrusted.** Check `Status`, don't echo\r\n the whole body back to the player, and never write an unvalidated field\r\n straight into player data.\r\n- **Prefer a dedicated module when one exists.** Cloud Code has no typed\r\n contract, no cache integration, and no per-feature event — reach for it only\r\n when the feature genuinely isn't covered elsewhere.\r\n- **Publishing replaces everything.** A revision is the whole script: publish\r\n one containing only your new handler and every other handler stops existing,\r\n with the game getting `HandlerNotFound` at runtime and nothing failing at\r\n build time. Always read the live source first and extend it.\r\n- **The handler whitelist is separate from the code.** A title can declare the\r\n handlers it allows; a function that exists in the script but not in that list\r\n is rejected with `HandlerNotFound`. When you add a handler to a title that\r\n uses a whitelist, add it to the list in the same publish.\r\n",
|
|
3
|
+
"description": "Write and call custom server-side game logic on the iDosGames platform: author a CloudCode handler (sandboxed JavaScript with a server.* API) and invoke it from the game via client.cloudCode (CloudCodeService) with an arbitrary JSON payload. Use this whenever the user wants bespoke server logic, a \"cloud script\", \"cloud function\" or \"server callable\", logic that must be authoritative (granting rewards, validating a reported result, anti-cheat), a write to protected player data (UserCustomData ReadOnly / Internal buckets) or to shared title state (TitleCustomData Runtime scope), or otherwise touches client.cloudCode, CloudCodeService, handlers, server.SetUserCustomData, server.IncrementTitleCustomData, server.HttpRequest or ExecuteCloudCodeResponse — even if they don't name the module explicitly. Also covers integrating a title with a third-party service (calling an external API with a stored API key, webhooks out, payment or analytics providers) and the {{secret:NAME}} / {{var:NAME}} placeholders.",
|
|
4
|
+
"content": "---\nname: cloud-code\ndescription: >-\n Write and call custom server-side game logic on the iDosGames platform:\n author a CloudCode handler (sandboxed JavaScript with a server.* API) and\n invoke it from the game via client.cloudCode (CloudCodeService) with an\n arbitrary JSON payload. Use this whenever the user wants bespoke server\n logic, a \"cloud script\", \"cloud function\" or \"server callable\", logic that\n must be authoritative (granting rewards, validating a reported result,\n anti-cheat), a write to protected player data (UserCustomData ReadOnly /\n Internal buckets) or to shared title state (TitleCustomData Runtime scope),\n or otherwise touches client.cloudCode, CloudCodeService, handlers,\n server.SetUserCustomData, server.IncrementTitleCustomData,\n server.HttpRequest or ExecuteCloudCodeResponse — even if they don't name the\n module explicitly. Also covers integrating a title with a third-party service\n (calling an external API with a stored API key, webhooks out, payment or\n analytics providers) and the {{secret:NAME}} / {{var:NAME}} placeholders.\n---\n\n# Cloud Code (iDosGames TS SDK)\n\nCloud Code runs **your** JavaScript on the platform's servers. A script defines\nnamed handlers; the game calls one by name and gets back whatever JSON it\nreturns.\n\nTwo distinct reasons to reach for it:\n\n1. **Authority.** Client code that \"grants\" a reward, \"validates\" a score or\n \"unlocks\" a level is a suggestion — the player owns the browser. Logic whose\n outcome must be trusted belongs in a handler.\n2. **Protected data.** The `ReadOnly`/`Internal` buckets of a player's\n UserCustomData and the `Runtime` scope of the title's data store have no\n client write path at all. A handler is the only way to write them.\n\nIf a dedicated module already covers what you need (currency, item, store,\nquest, character, leaderboard…), prefer that module — it gives you typed\nrequest/response shapes and cache integration; Cloud Code gives you neither.\n\nPublishing a script is a platform operation, not an SDK one: the AI Coder does\nit with its `SaveCloudCode` tool, an external agent with the backend MCP's\n`publish_cloud_code`, and a publisher from the dashboard. Publishing **replaces\nthe whole revision** — read the current source first (`GetCloudCode` with\n`include_code`, or `get_cloud_code`) and send it back with your handler added,\nor you silently delete every handler the game still calls.\n\n## Mental model\n\nThere is exactly one client-facing action: `execute`. You pass a **function\nname** (a handler defined in the title's deployed script's `handlers` object)\nand an optional **arbitrary JSON payload**; the server runs it in a sandboxed\nJS engine and returns an arbitrary JSON result plus execution metadata (logs,\ntiming, error info). The SDK has no idea what a given script's args or result\nlook like — **you** know your title's script contract, so you type the\npayload and result yourself (see Gotchas). There's no \"config vs state\" split\nhere like other modules — Cloud Code has no persistent per-player data model\nof its own; it's pure request/response.\n\nScript failure is a **first-class outcome, not a network error**: if the\nscript throws, times out, is rate-limited, or the handler doesn't exist, the\ncall still comes back `{ ok: true, data }` with `data.Error` populated and\n`data.FunctionResult` empty. Only infrastructure problems (not logged in, bad\nlocal args, connection issues, backend down) surface as `{ ok: false }`.\n\n## Writing a handler\n\nA revision is one plain JavaScript file that fills the global `handlers` object.\nNo imports, no modules, no `async`/`await`, no `fetch` — everything you can touch\nis on `server.*` and `log.*`, and every call is synchronous. Outbound network\naccess exists but only through `server.HttpRequest`, and only to hosts the\npublisher allow-listed (see _Calling another service_).\n\n```js\nhandlers.claimDailyBonus = function (args, context) {\n // context: { UserID, FunctionName, Revision, InvokedAt }\n var data = server.GetUserCustomData();\n if (!data.Success) throw new Error(data.Error);\n\n var last = data.Data.ReadOnly[\"daily_claimed_at\"];\n var today = new Date().toISOString().slice(0, 10);\n if (last && last.Value === today)\n return { granted: false, reason: \"already_claimed\" };\n\n var write = server.SetUserCustomData(\"ReadOnly\", \"daily_claimed_at\", today);\n if (!write.Success) throw new Error(write.Error);\n\n server.IncrementTitleCustomData(\"Public\", \"daily_claims_total\", 1);\n log.Info(\"daily bonus granted\", { user: context.UserID });\n return { granted: true };\n};\n```\n\n### The `server.*` API\n\nEvery call returns `{ Success, Error, Data }` — **check `Success`**; a rejected\nwrite (limit hit, wrong bucket, version conflict) is a normal result, not a\nthrow. Each call also counts against the per-execution API budget, so batch.\n\n| Call | What it does |\n| ----------------------------------------------------------------- | ------------------------------------------------------------------ |\n| `server.ReadUserData([\"InventoryV2\", \"Premium\", …])` | Read whitelisted sections of the caller's player document. |\n| `server.GetTitleConfig(\"Currency\", \"Item\", …)` | Read the title's configuration sections. |\n| `server.GetUserCustomData()` | All four buckets of the caller, **including `Internal`**. |\n| `server.GetPublicUserCustomDataOf(userId)` | Another player's `Public` bucket. |\n| `server.SetUserCustomData(bucket, key, value)` | Write any bucket — this is the protected-data write. |\n| `server.DeleteUserCustomData(bucket, key)` | Delete a key from any bucket (idempotent). |\n| `server.BatchSetUserCustomData([{Bucket, KeyID, Value}, …])` | Atomic multi-key write (all-or-nothing). |\n| `server.BatchDeleteUserCustomData([{Bucket, KeyID}, …])` | Atomic multi-key delete. |\n| `server.GetTitleCustomData()` | Title store: both scopes, both buckets. |\n| `server.SetTitleCustomData(bucket, key, value, expectedVersion?)` | Write the title's `Runtime` scope; pass a version for CAS. |\n| `server.IncrementTitleCustomData(bucket, key, delta)` | Atomic counter on shared data — use this, never read-modify-write. |\n| `server.DeleteTitleCustomData(bucket, key)` | Delete a `Runtime` key. |\n| `server.BatchSetTitleCustomData` / `BatchDeleteTitleCustomData` | Atomic multi-key variants for the title store. |\n| `server.GetIntegrationVariable(name)` | Read a non-secret integration setting (base URL, account id). |\n| `server.HttpRequest({ Method, Url, Headers, Body, ContentType })` | Call an external API — the only way out of the sandbox. |\n| `server.AddQuestProgress(metricID, value)` | Advance quest objectives configured with `Source: \"ServerApi\"`. |\n\n`server.AddQuestProgress` is the only way to move a `ServerApi` objective —\nneither the client nor the dashboard can touch those. Use it when only the server\nknows the fact (anti-cheat verdict, match result, an external system confirming\nvia `server.HttpRequest`). Unlike the client's `addQuestProgress` it neither bans\nnor clamps on `MaxProgressPerCall`: the script is written by the title owner, so\nthe value is trusted. It still clamps to the objective's `TargetValue`. Quests\nwhose objectives use `ClientApi` or `SystemEvent` are unreachable from here.\n\n`log.Debug/Info/Warning/Error(message, data?)` records a line the publisher sees\n(and, if the title reveals logs, the client too). It costs no API budget.\n\nNotes that bite:\n\n- Bucket and scope names are **case-sensitive strings**: `\"Private\"`,\n `\"Public\"`, `\"ReadOnly\"`, `\"Internal\"` for player data; `\"Public\"`,\n `\"Private\"` for title data. Anything else comes back as an error result.\n- Title writes always land in the `Runtime` scope — the `Static` scope is\n authored configuration and a script cannot touch it.\n- Shared counters must go through `IncrementTitleCustomData` (or\n `SetTitleCustomData` with `expectedVersion` from the record you read).\n Read-then-write from two concurrent calls silently loses one of them.\n- `throw` inside a handler is fine — it reaches the caller as a script-level\n error with your message, which is usually what you want for \"not allowed\".\n\n### Calling another service\n\nA handler can call a third-party API. The credential never appears in your code:\nyou reference it by placeholder and the platform substitutes it after your script\nhas run, immediately before the request leaves.\n\n```js\nhandlers.notifyDiscord = function (args, context) {\n var res = server.HttpRequest({\n Method: \"POST\",\n Url: \"https://discord.com/api/webhooks/{{var:DISCORD_WEBHOOK_PATH}}\",\n Headers: { Authorization: \"Bearer {{secret:DISCORD_TOKEN}}\" },\n Body: JSON.stringify({ content: \"Player \" + context.UserID + \" won!\" }),\n });\n if (!res.Success) throw new Error(res.Error); // network/policy failure\n if (!res.Data.Ok) return { sent: false, status: res.Data.Status };\n return { sent: true };\n};\n```\n\n- `{{secret:NAME}}` — an API key or token. **You can never read its value**, in\n any tool or any call; there is no `GetSecret`. That is deliberate: a value in\n JS could be returned to the player or logged by accident.\n- `{{var:NAME}}` — a non-secret setting. Also readable with\n `server.GetIntegrationVariable(name)` when you need it as a value.\n- `res.Data` is `{ Status, Ok, Body, BodyTooLarge, ContentType }`. `Body` is a\n string — parse it yourself; anything matching a substituted secret is replaced\n with `***` before you see it.\n\nWhat the platform enforces, and what you cannot work around from a script:\n\n- **Only allow-listed hosts.** The publisher lists them per title; there is no\n allow-all. An unlisted host fails with a clear message — surface it rather than\n retrying.\n- **https only** (unless the title explicitly allows plain http), **no\n redirects**, and no requests to private/loopback addresses.\n- **Per-execution request cap** (3 by default) and a **response size cap** — an\n oversized body is dropped, not truncated, with `BodyTooLarge: true`.\n- The whole call still lives inside the 10-second execution budget, so one slow\n integration can starve everything after it.\n\nIf the credential or the host you need does not exist yet, say exactly what has\nto be added in the title's **Integrations** settings — you cannot add either one.\n\n### Limits you are designing against\n\n10 seconds of wall-clock per call (hard, whatever the title configures), a cap\non statements and recursion depth, a cap on `server.*` calls per execution, and\nbyte ceilings on the returned result and the logs. Handlers are short decisions,\nnot jobs.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst cloudCode = client.cloudCode; // the CloudCodeService\n```\n\nRequires an authenticated session — without one, `execute` returns\n`{ ok: false, reason: \"unauthorized\" }` rather than making a request.\n\n## Methods\n\n`execute` returns `Promise<OperationResult<ExecuteCloudCodeResponse>>`: either\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data` — and then check `data.Error` before\ntrusting `data.FunctionResult` (see below). `reason` is one of `\"client\"`\n(empty/whitespace-only function name), `\"unauthorized\"`, `\"throttled\"` (fired\nthe same call again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"`\n(infrastructure-level rejection — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------- |\n| `execute(functionName, functionParameter?, revisionSelection?, specificRevision?)` | Run a title-defined cloud script handler by name. | `ExecuteCloudCodeResponse` |\n\nParameters:\n\n- `functionName` — the handler name inside the deployed script's `handlers`\n object. Case-sensitive; trimmed before sending. Client-side, only\n empty/whitespace is rejected (`reason: \"client\"`). Server-side, the backend\n additionally rejects (as a script-level `InvalidFieldName` error, not an\n `OperationResult` failure) names containing `.`, `$`, whitespace, or control\n characters, or longer than 128 characters — these are illegal as MongoDB\n field names since the name can end up in audit/log paths.\n- `functionParameter?` — any `JsonValue` (object, array, string, number,\n boolean, or null) passed as the handler's first argument. Omit if the script\n needs no input. If it's an object (at any nesting depth), none of its keys\n may contain `.` or `$` — the backend rejects such payloads with a\n script-level `InvalidFieldName` error before the script ever runs.\n- `revisionSelection?` — `\"Live\"` (default when omitted), `\"Latest\"`, or\n `\"Specific\"`. Lets you target a non-live revision for testing.\n- `specificRevision?` — the revision number to run; only used when\n `revisionSelection` is `\"Specific\"`.\n\n`ExecuteCloudCodeResponse` shape:\n\n| Field | Meaning |\n| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |\n| `FunctionName` | Echo of the handler that ran. |\n| `Revision` | Which revision actually executed. |\n| `FunctionResult` | The script's return value — arbitrary JSON, `null` if it returned nothing or on error. |\n| `FunctionResultTooLarge` | `true` if the result was dropped for exceeding the title's result-size limit (`FunctionResult` is `null` in that case). |\n| `Logs` | Array of `{ Level, Message?, Data? }` entries from `log.debug/info/warn/error` calls inside the script. |\n| `LogsTooLarge` | `true` if logs were truncated for exceeding the title's log-size limit. |\n| `ExecutionTimeSeconds` | Server-side wall-clock execution duration. |\n| `APIRequestsIssued` | Count of server API calls the script made internally (e.g. reading user data) — counts toward a per-execution cap. |\n| `Error` | `{ Error: CloudCodeErrorCode, Message?, StackTrace? }`, present only when the script failed or never ran; `null`/absent on success. |\n\n`CloudCodeErrorCode` values: `None`, `Disabled`, `NoActiveRevision`,\n`RevisionNotFound`, `InvalidFieldName`, `RateLimited`, `HandlerNotFound`,\n`HandlerDisabled`, `Timeout`, `StatementCountExceeded`, `StackOverflow`,\n`ApiCallLimitExceeded`, `JavaScriptException`, `ExecutionError` — stable, safe\nto switch on for retry/UX logic (e.g. treat `RateLimited`/`Timeout` as\nretryable, others as not).\n\nOn success, the SDK emits an event — it does **not** write anything into\n`client.data`, since the result shape is script-specific and there's no\ngeneric cache slot for it. If your script mutates player state (grants\ncurrency, items, etc. via server-side APIs), re-fetch that state through its\nowning module afterward — Cloud Code itself won't refresh your local cache.\n\n## Events\n\nSubscribe with `client.on(...)`; returns an unsubscribe fn.\n\n- `cloudCode:executed` → `ExecuteCloudCodeResponse` — fired whenever `execute` returns `{ ok: true }`, regardless of whether the script itself succeeded (check `data.Error` inside the handler).\n\n```ts\nconst off = client.on(\"cloudCode:executed\", (r) => {\n if (r.Error) console.warn(\"script failed:\", r.Error.Error, r.Error.Message);\n});\n// later: off();\n```\n\n## Recipes\n\n### Call a script and handle both failure layers\n\n```ts\ninterface GrantBonusArgs {\n reason: string;\n}\ninterface GrantBonusResult {\n granted: number;\n}\n\nconst args: GrantBonusArgs = { reason: \"daily\" };\nconst result = await client.cloudCode.execute(\"grantLoginBonus\", args);\nif (!result.ok) return showError(result.error ?? result.reason); // infra-level failure\n\nif (result.data.Error) {\n return showError(result.data.Error.Message ?? result.data.Error.Error); // script-level failure\n}\n\nconst payload = result.data.FunctionResult as GrantBonusResult; // your contract — cast/validate it yourself\nconsole.log(`granted ${payload.granted}`);\n```\n\n### Fire-and-forget script with no input\n\n```ts\nconst result = await client.cloudCode.execute(\"resetDailyQuests\");\nif (!result.ok || result.data.Error) {\n console.warn(\"resetDailyQuests failed\", result.error ?? result.data.Error);\n}\n```\n\n### Test against a specific revision before it goes live\n\n```ts\nconst result = await client.cloudCode.execute(\n \"computeMatchReward\",\n { matchID },\n \"Specific\",\n 42, // revision number\n);\n```\n\n### Surface script logs during development\n\n```ts\nconst result = await client.cloudCode.execute(\"debugScript\", { x: 1 });\nif (result.ok) {\n for (const log of result.data.Logs ?? []) {\n console.log(`[${log.Level}]`, log.Message, log.Data);\n }\n}\n```\n\nLogs only come back at all if the title has logs enabled for clients; on\ntitles that don't, `Logs` is always an empty array even though the script did\nlog server-side — don't treat an empty array as proof the script logged\nnothing.\n\n### Chain a cloud-code call with a resource refresh\n\n```ts\nconst res = await client.cloudCode.execute(\"craftSpecialItem\", { recipeID });\nif (!res.ok || res.data.Error) return showError(res.error ?? res.data.Error);\n\n// The script granted items/currency server-side — Cloud Code didn't touch the\n// cache, so pull the owning module's state to see the new balance/inventory.\nawait client.user.getClientState(); // or the specific module's getter, e.g. client.item...\n```\n\n## Gotchas\n\n- **Two failure layers, don't conflate them.** `result.ok === false` means the\n call itself failed (auth, bad args, connection) — the script never ran or\n its outcome is unknown. `result.ok === true && result.data.Error` means the\n call succeeded but the _script_ failed (threw, timed out, disabled,\n unknown/undeclared handler, rate-limited) — always check both before\n trusting `FunctionResult`.\n- **Unknown handler is a script-level error, not a client-side check.** The\n SDK never validates that `functionName` refers to a real handler — that's\n entirely server-side. Depending on the title's config you can get\n `HandlerNotFound` either because the name isn't in the title's declared\n handler whitelist, or because the deployed script simply never defined\n `handlers[functionName]`; both look the same to the caller. A handler can\n also be individually killed by an admin, which comes back as\n `HandlerDisabled`.\n- **A hard 10-second ceiling always applies.** Whatever timeout the title/\n revision configures, the backend clamps every single execution to a 10\n second wall-clock budget; past that you get `Timeout` no matter what. Don't\n design a script-based feature around long-running work.\n- **Rate limiting can hit independently of the generic per-endpoint throttle.**\n Beyond the SDK's own ~600ms client-side throttle per call and the\n transport's per-user rate limit, the title can configure CloudCode-specific\n limits at three levels — whole title, this user, or this user+handler pair.\n Any of them tripping comes back as `data.Error.Error === \"RateLimited\"`\n (an in-band script-level outcome, `result.ok` is still `true`), with\n `data.Error.Message` naming which layer triggered it — treat it as\n retryable-after-a-delay, not a hard failure.\n- **No client-side validation of script logic.** The SDK only validates that\n `functionName` is non-empty and that you're logged in. Argument shape,\n business rules, and error handling are entirely up to the script — a\n malformed `functionParameter` will fail server-side (`JavaScriptException`\n or similar), not client-side.\n- **Type the payload and result yourself.** `functionParameter` is `JsonValue`\n and `FunctionResult` is `JsonValue | null` — the SDK has no schema for your\n title's specific scripts. Define your own request/response interfaces per\n handler (as in the recipes above) and cast/validate after the call.\n- **Cloud Code doesn't touch `client.data`.** Unlike feature modules, a\n successful `execute` doesn't mirror anything into the cache. If the script\n changed player-facing state, re-fetch it via the owning module (e.g. call\n the Economy/Item/Character module's getter) so the UI reflects it.\n- **`Logs`/`FunctionResult` can be silently dropped.** Both are subject to a\n title-configured byte-size ceiling; check `LogsTooLarge` /\n `FunctionResultTooLarge` before assuming absence means the script produced\n nothing. Whether `Logs` is populated at all (even under the size limit) also\n depends on a title setting — some titles never reveal script logs to\n clients.\n- **Keys in your JSON payload can't contain `.` or `$`.** This is a MongoDB\n field-name restriction the backend enforces recursively on\n `functionParameter` (and on whatever the script returns) — a payload with a\n dotted or `$`-prefixed key fails with `InvalidFieldName` before the script\n even starts. Stick to plain alphanumeric/underscore keys.\n- **Never put a third-party key in game code.** The project ships to the\n player's browser; a key there is a public key. The call belongs in a handler,\n and the key belongs in the title's integration store.\n- **Treat an integration's response as untrusted.** Check `Status`, don't echo\n the whole body back to the player, and never write an unvalidated field\n straight into player data.\n- **Prefer a dedicated module when one exists.** Cloud Code has no typed\n contract, no cache integration, and no per-feature event — reach for it only\n when the feature genuinely isn't covered elsewhere.\n- **Publishing replaces everything.** A revision is the whole script: publish\n one containing only your new handler and every other handler stops existing,\n with the game getting `HandlerNotFound` at runtime and nothing failing at\n build time. Always read the live source first and extend it.\n- **The handler whitelist is separate from the code.** A title can declare the\n handlers it allows; a function that exists in the script but not in that list\n is rejected with `HandlerNotFound`. When you add a handler to a title that\n uses a whitelist, add it to the list in the same publish.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|