@idosgames/mcp 0.1.3 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  {
13
13
  "path": "package.json",
14
- "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.1.7\",\n \"@idosgames/core\": \"0.2.0\",\n \"@idosgames/module-sdk\": \"0.1.3\",\n \"@idosgames/react\": \"0.1.1\",\n \"@idosgames/wallet\": \"0.1.13\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
14
+ "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.1.12\",\n \"@idosgames/core\": \"0.6.0\",\n \"@idosgames/module-sdk\": \"0.1.7\",\n \"@idosgames/react\": \"0.1.5\",\n \"@idosgames/wallet\": \"0.1.17\",\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",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedFromCommit": "d12deae18316d281a4c5eb90f407ee19464cf29d",
2
+ "generatedFromCommit": "86978ebd7494a59d7c8aa58ce80f17ca7e053486",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.2.0",
5
- "@idosgames/wallet": "0.1.13",
6
- "@idosgames/module-sdk": "0.1.3",
7
- "@idosgames/react": "0.1.1",
8
- "@idosgames/app-shell": "0.1.7"
4
+ "@idosgames/core": "0.6.0",
5
+ "@idosgames/wallet": "0.1.17",
6
+ "@idosgames/module-sdk": "0.1.7",
7
+ "@idosgames/react": "0.1.5",
8
+ "@idosgames/app-shell": "0.1.12"
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.2.0",
50
- "@idosgames/module-sdk": "0.1.3",
51
- "@idosgames/react": "0.1.1",
52
- "@idosgames/wallet": "0.1.13",
49
+ "@idosgames/core": "0.6.0",
50
+ "@idosgames/module-sdk": "0.1.7",
51
+ "@idosgames/react": "0.1.5",
52
+ "@idosgames/wallet": "0.1.17",
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.2.0",
96
- "@idosgames/module-sdk": "0.1.3",
97
- "@idosgames/react": "0.1.1",
98
- "@idosgames/wallet": "0.1.13",
95
+ "@idosgames/core": "0.6.0",
96
+ "@idosgames/module-sdk": "0.1.7",
97
+ "@idosgames/react": "0.1.5",
98
+ "@idosgames/wallet": "0.1.17",
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.3",
141
+ "@idosgames/module-sdk": "0.1.7",
142
142
  "three": "0.185.1"
143
143
  },
144
144
  "fileCount": 38
@@ -159,7 +159,7 @@
159
159
  },
160
160
  {
161
161
  "name": "cloud-code",
162
- "description": ""
162
+ "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
163
  },
164
164
  {
165
165
  "name": "collection-system",
@@ -217,6 +217,10 @@
217
217
  "name": "leaderboard-system",
218
218
  "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
219
  },
220
+ {
221
+ "name": "localization-system",
222
+ "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."
223
+ },
220
224
  {
221
225
  "name": "lootbox-system",
222
226
  "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."
@@ -239,7 +243,7 @@
239
243
  },
240
244
  {
241
245
  "name": "quest-system",
242
- "description": ""
246
+ "description": "Build a quest / daily-task system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.quest (QuestService): load quest and cycle definitions, load the player's quest progress state, add progress toward a metric, claim a completed quest's reward, claim a points-track milestone reward, claim a group-completion (grand) reward, and refresh cycles (dailies/ weeklies) forward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest screens, task lists, objective/progress trackers, battle-pass-style points tracks, milestone reward ladders, quest-group completion bonuses, or otherwise touches client.quest, QuestService, QuestDefinitions, UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they don't name the module explicitly."
243
247
  },
244
248
  {
245
249
  "name": "referral-system",
@@ -277,6 +281,10 @@
277
281
  "name": "title-system",
278
282
  "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
283
  },
284
+ {
285
+ "name": "tutorial-system",
286
+ "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."
287
+ },
280
288
  {
281
289
  "name": "user-custom-data",
282
290
  "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.2.0",
41
- "@idosgames/module-sdk": "0.1.3",
42
- "@idosgames/react": "0.1.1",
43
- "@idosgames/wallet": "0.1.13",
40
+ "@idosgames/core": "0.6.0",
41
+ "@idosgames/module-sdk": "0.1.7",
42
+ "@idosgames/react": "0.1.5",
43
+ "@idosgames/wallet": "0.1.17",
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.2.0",
41
- "@idosgames/module-sdk": "0.1.3",
42
- "@idosgames/react": "0.1.1",
43
- "@idosgames/wallet": "0.1.13",
40
+ "@idosgames/core": "0.6.0",
41
+ "@idosgames/module-sdk": "0.1.7",
42
+ "@idosgames/react": "0.1.5",
43
+ "@idosgames/wallet": "0.1.17",
44
44
  "@tanstack/react-query": "5.101.2",
45
45
  "phaser": "4.2.1",
46
46
  "react": "19.2.7",
@@ -37,7 +37,7 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/module-sdk": "0.1.3",
40
+ "@idosgames/module-sdk": "0.1.7",
41
41
  "three": "0.185.1"
42
42
  },
43
43
  "files": [
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "collection-system",
3
3
  "description": "Build a collection / sticker-album / TCG system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.collection (CollectionService): open collectible packs and pity-driven collection chests, spend \"joker\" wildcards to fill a specific slot, claim set-completion rewards (single + batch) and the collection Grand Prize, and run peer-to-peer collectible trading (send/cancel/accept/decline trade offers, list my/incoming offers). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a sticker album, TCG-style collection/set-completion screen, pack-opening UI, duplicate/pity systems, or player-to-player item trading — or otherwise touches client.collection, CollectionService, CollectionDefinitions, UserCollectionState, or trade offers — even if they don't name the module explicitly.",
4
- "content": "---\nname: collection-system\ndescription: >-\n Build a collection / sticker-album / TCG system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.collection (CollectionService):\n open collectible packs and pity-driven collection chests, spend \"joker\"\n wildcards to fill a specific slot, claim set-completion rewards (single +\n batch) and the collection Grand Prize, and run peer-to-peer collectible\n trading (send/cancel/accept/decline trade offers, list my/incoming offers).\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a sticker album, TCG-style\n collection/set-completion screen, pack-opening UI, duplicate/pity systems,\n or player-to-player item trading — or otherwise touches client.collection,\n CollectionService, CollectionDefinitions, UserCollectionState, or trade\n offers — even if they don't name the module explicitly.\n---\n\n# Collection system (iDosGames TS SDK)\n\nThe Collection module is a \"sticker album\": a title defines one or more\n**Collections**, each made of thematic **Sets** (\"pages\"), each Set made of\n**Collectibles** (\"stickers\", each optionally with a rarer Special version).\nPlayers fill the album by opening **Packs** (lootboxes) and **Collection\nChests** (pity-driven, bought with Collection Currency earned from\nduplicates), can burn a **Joker** wildcard to fill one specific missing\nCollectible, claim a reward when a Set is completed, and claim a **Grand\nPrize** when the whole Collection is completed. A separate **trading**\nsub-system lets players swap Collectibles peer-to-peer.\n\nEverything is **server-authoritative**, same contract as the rest of the SDK:\ncall a method, check `result.ok`, render from the mirrored cache. This skill\nis for **using** the production `CollectionService`, not porting or extending\nit — a rejection is the backend enforcing a rule, surface the error rather\nthan reproducing the check client-side.\n\nThis module frequently sits next to [item-system](../item-system/SKILL.md) or\ncharacter loadouts — Collectibles are a separate currency-and-progress track\nfrom `client.item`/`client.character`, not items themselves, though a title\nmay reward items via `SetCompletionReward` / `GrandPrize`.\n\n## The two data shapes\n\n1. **Definitions** (config) — the title's catalog: `Collections` (each with\n `Sets`, each with `Collectibles`), `PackTypes` (lootbox-style openable\n packs), `CollectionChests` (pity-buy chests priced in Collection\n Currency), `DuplicateConversions` (duplicate → currency rate by rarity),\n `DailyTradeLimit`, the joker's `CollectibleJokerCatalogID` /\n `CollectibleJokerItemID`, and `SpecialTradeEvents` (time windows that\n unlock Special-collectible trading). Fetched with `getDefinitions()`.\n2. **User state** (state, per player) — `CollectionCurrencyBalance`,\n `OwnedCollectibles` / `OwnedSpecialCollectibles` (id → count),\n `ClaimedSetRewards`, `IsCollectionCompleted`, `GrandPrizeClaimed`,\n `DailyTradesSent` (+ reset date), `PendingTradeOfferIDs`, and pity\n `PityCounters`. Fetched with `getUserState()`. **This state object is\n stored wholesale in the cache and typed leniently (`Record`-style\n passthrough)** — read fields defensively (`?.`), don't assume every field\n is always present.\n\nFor the full field-by-field shape, formulas for duplicate conversion, and the\ntrade-offer document shape, read\n[references/data-model.md](references/data-model.md).\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 collection = client.collection; // the CollectionService\n```\n\nEvery method requires an authenticated session; without one they return\n`{ ok: false, reason: \"unauthorized\" }` — none of them throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: `{ ok: true, data }` or\n`{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is one\nof `\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (same\nendpoint fired again inside the 600ms default window), `\"connection\"`\n(transient — offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` has the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's collection catalog (config). | `CollectionDefinitions` |\n| `getUserState()` | Load this player's collection progress (state). | `UserCollectionState` |\n| `openPack(collectionID, packTypeID)` | Open one pack (charges the pack's `Cost`). | `OpenPackResponse` |\n| `openCollectionChest(collectionID, collectionChestID)` | Open a pity chest (charges Collection Currency). | `OpenCollectionChestResponse` |\n| `useCollectibleJoker(collectionID, collectibleID)` | Burn one Joker item to grant a specific Collectible. | `UseCollectibleJokerResponse` |\n| `claimSetReward(collectionID, setID)` | Claim a completed Set's reward. | `ClaimSetRewardResponse` |\n| `claimSetRewardsBatch(sets)` | Claim several completed Sets in one atomic call (deduped by SetID). | `ClaimSetRewardsBatchResponse` (`BatchItemResult<ClaimSetRewardResponse>[]`) |\n| `claimGrandPrize(collectionID)` | Claim the Grand Prize once the whole Collection is completed. | `ClaimGrandPrizeResponse` |\n| `sendTradeOffer(collectionID, collectibleID, collectibleIsSpecial, receiverUserID, requestedCollectibleID?, requestedCollectibleIsSpecial?)` | Offer one of your Collectibles to another player, optionally requesting a specific one back. | `SendTradeOfferResponse` |\n| `cancelTradeOffer(offerID)` | Cancel a trade offer you sent. | `CancelTradeOfferResponse` |\n| `acceptTradeOffer(offerID)` | Accept an incoming trade offer (transfers both sides). | `AcceptTradeOfferResponse` |\n| `declineTradeOffer(offerID)` | Decline an incoming trade offer. | `DeclineTradeOfferResponse` |\n| `getMyTradeOffers(collectionID)` | List trade offers you've sent for a collection. | `GetTradeOffersResponse` (`{ Offers: CollectionTradeOfferDocument[] }`) |\n| `getIncomingTradeOffers(collectionID)` | List trade offers sent to you for a collection. | `GetTradeOffersResponse` |\n\nOn success, resource-affecting methods (`openPack`, `openCollectionChest`,\n`useCollectibleJoker`, `claimSetReward`, `claimSetRewardsBatch`,\n`claimGrandPrize`) mirror `data.Resources` (a `ResourceOperation`) into the\ncached currency/item balances — read updated balances straight from\n`client.data.user`. **Trade-offer methods do not touch resource balances or\nthe `Collection` cache slice** — they're domain-only actions that surface\npurely through their event; refetch `getUserState()` / `getMyTradeOffers()` /\n`getIncomingTradeOffers()` to see the effect of a trade.\n\n## Reading state and reacting to changes\n\n```ts\n// Cached after getUserState():\nconst state = client.data.user.state?.Collection;\nstate?.CollectionCurrencyBalance;\nstate?.OwnedCollectibles; // { collectibleID: count }\nstate?.ClaimedSetRewards; // string[]\n\n// Cached after getDefinitions():\nimport type { CollectionDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `collection:definitionsLoaded` → `CollectionDefinitions`\n- `collection:userStateLoaded` → `UserCollectionState`\n- `collection:packOpened` → `OpenPackResponse`\n- `collection:chestOpened` → `OpenCollectionChestResponse`\n- `collection:jokerUsed` → `UseCollectibleJokerResponse`\n- `collection:setRewardClaimed` → `ClaimSetRewardResponse`\n- `collection:setRewardsClaimedBatch` → `ClaimSetRewardsBatchResponse`\n- `collection:grandPrizeClaimed` → `ClaimGrandPrizeResponse`\n- `collection:tradeOfferSent` → `SendTradeOfferResponse`\n- `collection:tradeOfferCancelled` → `CancelTradeOfferResponse`\n- `collection:tradeOfferAccepted` → `AcceptTradeOfferResponse`\n- `collection:tradeOfferDeclined` → `DeclineTradeOfferResponse`\n- `collection:myTradeOffersLoaded` → `GetTradeOffersResponse`\n- `collection:incomingTradeOffersLoaded` → `GetTradeOffersResponse`\n\nThe coarse `user:collectionUpdated` (+ umbrella `user:anyUpdated`) fires only\nfrom `getUserState()` (it's emitted by `applyCollection`, the whole-state\ncache write) — it does **not** fire from pack/chest/joker/claim calls, since\nthose patch resource balances rather than the `Collection` state slice\ndirectly. Re-`getUserState()` after those calls (or after a trade) if you need\nthe cached collection progress to reflect the change.\n\n```ts\nconst off = client.on(\"collection:packOpened\", (r) => {\n for (const c of r.GrantedCollectibles ?? []) console.log(c.CollectibleID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the album and open a pack\n\n```ts\nawait client.collection.getDefinitions();\nawait client.collection.getUserState();\n\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\nconst owned = client.data.user.state?.Collection?.OwnedCollectibles ?? {};\n\nconst pack = await client.collection.openPack(\"main-collection\", \"starter\");\nif (!pack.ok) return showError(pack.error); // e.g. insufficient currency/items\nfor (const c of pack.data.GrantedCollectibles ?? []) {\n // new sticker; check pack.data.DuplicateCollectibles for ones already owned\n}\nif (pack.data.CollectionJustCompleted) showGrandPrizeAvailable();\nfor (const setID of pack.data.NewlyCompletedSetIDs ?? [])\n showSetComplete(setID);\n\n// Balances (pack Cost debited, CollectionCurrencyEarned credited from\n// duplicate conversion) are already reflected here:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\n```\n\n### Spend Collection Currency on a pity chest, then a Joker\n\n```ts\nconst chest = await client.collection.openCollectionChest(\n \"main-collection\",\n \"silver-chest\",\n);\nif (!chest.ok) return showError(chest.error);\n// chest.data.NewCollectionCurrencyBalance reflects the debit; TriggeredPity\n// lists any pity rule(s) that fired on this open.\n\n// Jokers are a regular item (CollectibleJokerItemID in Definitions) burned\n// to grant one specific missing Collectible. Special versions can't be\n// targeted this way — pass collectibleIsSpecial via a Special-only Collectible\n// and it's rejected: \"CollectibleJoker cannot be used for Special Collectibles.\"\nconst joker = await client.collection.useCollectibleJoker(\n \"main-collection\",\n \"card-042\",\n);\nif (!joker.ok) return showError(joker.error); // e.g. \"already owned\", no joker item\nif (joker.data.CollectionJustCompleted) showGrandPrizeAvailable();\n```\n\n### Claim set rewards, then the Grand Prize\n\n```ts\nconst setClaim = await client.collection.claimSetReward(\n \"main-collection\",\n \"set-forest\",\n);\nif (!setClaim.ok) return showError(setClaim.error); // e.g. \"set not completed\", \"already claimed\"\n\nif (client.data.user.state?.Collection?.IsCollectionCompleted) {\n const grand = await client.collection.claimGrandPrize(\"main-collection\");\n if (!grand.ok) return showError(grand.error); // e.g. \"already claimed\"\n}\n```\n\n### Batch-claim several completed sets\n\n```ts\nconst res = await client.collection.claimSetRewardsBatch([\n { CollectionID: \"main-collection\", SetID: \"set-forest\" },\n { CollectionID: \"main-collection\", SetID: \"set-ocean\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) markClaimed(item.Id);\n else showItemError(item.Id, item.Error); // e.g. that set wasn't complete\n}\n```\n\nBatch results are **partial-aware**: `res.ok` says the call ran; each\nelement's `Success`/`Error` says whether that specific set's reward applied.\nOnly the **first** successful item's `Resources` is applied to the cache by\nthe SDK (a `resourcesApplied` guard short-circuits after the first) — if you\nneed every claimed set's grant reflected precisely, re-fetch balances (e.g.\n`client.user`/inventory refresh) after a multi-set batch rather than trusting\nthe cache to have summed them all.\n\n### Trade Collectibles peer-to-peer\n\n```ts\n// Offer my duplicate for a specific card back. Receiver must already be a friend\n// (see social-system) — the server rejects otherwise.\nconst sent = await client.collection.sendTradeOffer(\n \"main-collection\",\n \"card-011\", // CollectibleID I'm giving\n false, // not a Special version\n \"u2\", // receiver\n \"card-042\", // requested back (optional — omit for an open/gift offer)\n);\nif (!sent.ok) return showError(sent.error); // e.g. daily trade limit reached, don't own it\n\n// Receiver's side:\nconst incoming =\n await client.collection.getIncomingTradeOffers(\"main-collection\");\nfor (const offer of incoming.data?.Offers ?? []) {\n // offer.Status === \"Pending\" -> show Accept/Decline\n}\nconst accept = await client.collection.acceptTradeOffer(offer.OfferID);\nif (!accept.ok) return showError(accept.error); // e.g. offer expired, requested card no longer owned\naccept.data.ReceivedCollectibleID; // what I got\naccept.data.SentCollectibleID; // what I gave up\n\n// Sender can cancel while still Pending:\nawait client.collection.cancelTradeOffer(sent.data.OfferID);\n```\n\nRules enforced server-side, not client-side — surface the `error` string, don't\npre-validate:\n\n- **Receiver must be a friend.** `sendTradeOffer` rejects with \"Receiver must be\n in your friends list\" otherwise (see [social-system](../social-system/SKILL.md)\n to add them first).\n- **You need a spare copy to offer or request one back.** A normal Collectible\n needs `OwnedCollectibles[id] >= 2` to be offered or requested (one copy stays\n with you); a Special needs only `>= 1` (it moves entirely, no copy kept\n behind). Rejections read \"You need a duplicate (count >= 2) to trade this\n Collectible.\" / \"You don't own this Special Collectible.\"\n- **The receiver's inbox caps at 10 pending offers**; sending past that fails\n with \"Receiver has too many pending trade offers.\"\n- **Offers expire after 7 days** (168h) from creation — `ExpiresAtUtc` on the\n response and on `CollectionTradeOfferDocument`; `acceptTradeOffer` past that\n point fails with \"Offer has expired.\" Nothing ever flips the stored `Status`\n to `\"Expired\"` server-side, though — `getIncomingTradeOffers` just filters\n lapsed offers out of the list, while `getMyTradeOffers` keeps returning them\n as `Status: \"Pending\"` with a stale `ExpiresAtUtc`. Compare `ExpiresAtUtc`\n to now yourself when rendering your own sent-offers list.\n- Special-version Collectibles can normally only be traded during a\n `SpecialTradeEventDefinition` window (`AllowedSpecialCollectibleIDs`,\n `SpecialTradeEventDailyTradeLimit`) — offering **or being asked for** a\n Special outside that window is rejected server-side on both `sendTradeOffer`\n and `acceptTradeOffer`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key —\n two separate calls are two real operations. A double-clicked \"Open Pack\"\n can open (and charge) twice. Disable the control while a call is in\n flight; the 600ms default throttle window rejects same-endpoint spam with\n `reason: \"throttled\"` but isn't a substitute for disabling the button.\n- **`user:collectionUpdated` is a state-replace signal, not a delta signal.**\n It only fires from `getUserState()`. Don't wire \"refresh the album UI\" to\n it and expect pack/chest/claim calls to trigger it — listen to the\n specific action events (`collection:packOpened`, etc.) instead, or\n re-`getUserState()` after mutating actions if you need the state slice\n itself refreshed.\n- **Trade offers never touch resources or the `Collection` cache slice.**\n There's no automatic balance/inventory update from send/cancel/accept/\n decline — re-fetch `getUserState()` (and re-list offers) to see the\n post-trade picture.\n- **`claimSetRewardsBatch` only applies the first successful item's\n `Resources` to the cache.** If the batch claims multiple sets, don't assume\n the cached currency/item balances reflect all of them — verify against a\n fresh state fetch if the UI shows exact totals.\n- **`UserCollectionState` is loosely typed (passthrough over `{}`).** Unlike\n `CollectionDefinitions` (strictly typed), the per-player state interface is\n a best-effort shape — treat documented fields as likely-present, not\n guaranteed, and code defensively.\n- **Duplicates aren't wasted — they convert to Collection Currency** per\n `DuplicateConversions` (rate keyed by rarity), which is what funds\n `openCollectionChest`. `OpenPackResponse.DuplicateCollectibles` lists which\n pulls were duplicates and `CollectionCurrencyEarned` is the resulting\n credit for that pack.\n- **A season-linked collection can wipe out from under you.** If a\n `CollectionDefinition` has `SeasonChainID` set, the backend resets the\n player's entire `Collection` state (owned Collectibles, currency, claimed\n sets, pity, everything) the moment the linked season rolls over — lazily, on\n the next call that touches Collection. There's no client-side warning event\n for this; just always render from a fresh `getUserState()` rather than\n assuming yesterday's cache is still valid across a session boundary.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the pack/chest reward-slot and pity-rule shape, the trade-offer\ndocument lifecycle, and the joker/duplicate-conversion mechanics.\n",
4
+ "content": "---\nname: collection-system\ndescription: >-\n Build a collection / sticker-album / TCG system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.collection (CollectionService):\n open collectible packs and pity-driven collection chests, spend \"joker\"\n wildcards to fill a specific slot, claim set-completion rewards (single +\n batch) and the collection Grand Prize, and run peer-to-peer collectible\n trading (send/cancel/accept/decline trade offers, list my/incoming offers).\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a sticker album, TCG-style\n collection/set-completion screen, pack-opening UI, duplicate/pity systems,\n or player-to-player item trading — or otherwise touches client.collection,\n CollectionService, CollectionDefinitions, UserCollectionState, or trade\n offers — even if they don't name the module explicitly.\n---\n\n# Collection system (iDosGames TS SDK)\n\nThe Collection module is a \"sticker album\": a title defines one or more\n**Collections**, each made of thematic **Sets** (\"pages\"), each Set made of\n**Collectibles** (\"stickers\", each optionally with a rarer Special version).\nPlayers fill the album by opening **Packs** (lootboxes) and **Collection\nChests** (pity-driven, bought with Collection Currency earned from\nduplicates), can burn a **Joker** wildcard to fill one specific missing\nCollectible, claim a reward when a Set is completed, and claim a **Grand\nPrize** when the whole Collection is completed. A separate **trading**\nsub-system lets players swap Collectibles peer-to-peer.\n\nEverything is **server-authoritative**, same contract as the rest of the SDK:\ncall a method, check `result.ok`, render from the mirrored cache. This skill\nis for **using** the production `CollectionService`, not porting or extending\nit — a rejection is the backend enforcing a rule, surface the error rather\nthan reproducing the check client-side.\n\nThis module frequently sits next to [item-system](../item-system/SKILL.md) or\ncharacter loadouts — Collectibles are a separate currency-and-progress track\nfrom `client.item`/`client.character`, not items themselves, though a title\nmay reward items via `SetCompletionReward` / `GrandPrize`.\n\n## The two data shapes\n\n1. **Definitions** (config) — the title's catalog: `Collections` (each with\n `Sets`, each with `Collectibles`), `PackTypes` (lootbox-style openable\n packs), `CollectionChests` (pity-buy chests priced in Collection\n Currency), `DuplicateConversions` (duplicate → currency rate by rarity),\n `DailyTradeLimit`, the joker's `CollectibleJokerCatalogID` /\n `CollectibleJokerItemID`, and `SpecialTradeEvents` (time windows that\n unlock Special-collectible trading). Fetched with `getDefinitions()`.\n2. **User state** (state, per player) — `CollectionCurrencyBalance`,\n `OwnedCollectibles` / `OwnedSpecialCollectibles` (id → count),\n `ClaimedSetRewards`, `IsCollectionCompleted`, `GrandPrizeClaimed`,\n `DailyTradesSent` (+ reset date), `PendingTradeOfferIDs`, and pity\n `PityCounters`. Fetched with `getUserState()`. **This state object is\n stored wholesale in the cache and typed leniently (`Record`-style\n passthrough)** — read fields defensively (`?.`), don't assume every field\n is always present.\n\nFor the full field-by-field shape, formulas for duplicate conversion, and the\ntrade-offer document shape, read\n[references/data-model.md](references/data-model.md).\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 collection = client.collection; // the CollectionService\n```\n\nEvery method requires an authenticated session; without one they return\n`{ ok: false, reason: \"unauthorized\" }` — none of them throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: `{ ok: true, data }` or\n`{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is one\nof `\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (same\nendpoint fired again inside the 600ms default window), `\"connection\"`\n(transient — offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` has the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's collection catalog (config). | `CollectionDefinitions` |\n| `getUserState()` | Load this player's collection progress (state). | `UserCollectionState` |\n| `openPack(collectionID, packTypeID, count?)` | Open `count` packs (default 1) in one atomic call; cost scales with the count. `count` is clamped server-side to the pack type's `MaxOpenCount` → module `Settings.MaxPackOpenCount` → platform default (100). Read `OpenedCount` for what actually happened and `Packs` for the per-pack breakdown. | `OpenPackResponse` |\n| `openCollectionChest(collectionID, collectionChestID, count?)` | Open `count` pity chests (default 1) in one atomic call; cost scales with the count. Per-chest breakdown in `Chests`. | `OpenCollectionChestResponse` |\n| `useCollectibleJoker(collectionID, collectibleID)` | Burn one Joker item to grant a specific Collectible. | `UseCollectibleJokerResponse` |\n| `claimSetReward(collectionID, setID)` | Claim a completed Set's reward. | `ClaimSetRewardResponse` |\n| `claimSetRewardsBatch(sets)` | Claim several completed Sets in one atomic call (deduped by SetID). | `ClaimSetRewardsBatchResponse` (`BatchItemResult<ClaimSetRewardResponse>[]`) |\n| `claimGrandPrize(collectionID)` | Claim the Grand Prize once the whole Collection is completed. | `ClaimGrandPrizeResponse` |\n| `sendTradeOffer(collectionID, collectibleID, collectibleIsSpecial, receiverUserID, requestedCollectibleID?, requestedCollectibleIsSpecial?)` | Offer one of your Collectibles to another player, optionally requesting a specific one back. | `SendTradeOfferResponse` |\n| `cancelTradeOffer(offerID)` | Cancel a trade offer you sent. | `CancelTradeOfferResponse` |\n| `acceptTradeOffer(offerID)` | Accept an incoming trade offer (transfers both sides). | `AcceptTradeOfferResponse` |\n| `declineTradeOffer(offerID)` | Decline an incoming trade offer. | `DeclineTradeOfferResponse` |\n| `getMyTradeOffers(collectionID)` | List trade offers you've sent for a collection. | `GetTradeOffersResponse` (`{ Offers: CollectionTradeOfferDocument[] }`) |\n| `getIncomingTradeOffers(collectionID)` | List trade offers sent to you for a collection. | `GetTradeOffersResponse` |\n\nOn success, resource-affecting methods (`openPack`, `openCollectionChest`,\n`useCollectibleJoker`, `claimSetReward`, `claimSetRewardsBatch`,\n`claimGrandPrize`) mirror `data.Resources` (a `ResourceOperation`) into the\ncached currency/item balances — read updated balances straight from\n`client.data.user`. **Trade-offer methods do not touch resource balances or\nthe `Collection` cache slice** — they're domain-only actions that surface\npurely through their event; refetch `getUserState()` / `getMyTradeOffers()` /\n`getIncomingTradeOffers()` to see the effect of a trade.\n\n## Reading state and reacting to changes\n\n```ts\n// Cached after getUserState():\nconst state = client.data.user.state?.Collection;\nstate?.CollectionCurrencyBalance;\nstate?.OwnedCollectibles; // { collectibleID: count }\nstate?.ClaimedSetRewards; // string[]\n\n// Cached after getDefinitions():\nimport type { CollectionDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `collection:definitionsLoaded` → `CollectionDefinitions`\n- `collection:userStateLoaded` → `UserCollectionState`\n- `collection:packOpened` → `OpenPackResponse`\n- `collection:chestOpened` → `OpenCollectionChestResponse`\n- `collection:jokerUsed` → `UseCollectibleJokerResponse`\n- `collection:setRewardClaimed` → `ClaimSetRewardResponse`\n- `collection:setRewardsClaimedBatch` → `ClaimSetRewardsBatchResponse`\n- `collection:grandPrizeClaimed` → `ClaimGrandPrizeResponse`\n- `collection:tradeOfferSent` → `SendTradeOfferResponse`\n- `collection:tradeOfferCancelled` → `CancelTradeOfferResponse`\n- `collection:tradeOfferAccepted` → `AcceptTradeOfferResponse`\n- `collection:tradeOfferDeclined` → `DeclineTradeOfferResponse`\n- `collection:myTradeOffersLoaded` → `GetTradeOffersResponse`\n- `collection:incomingTradeOffersLoaded` → `GetTradeOffersResponse`\n\nThe coarse `user:collectionUpdated` (+ umbrella `user:anyUpdated`) fires only\nfrom `getUserState()` (it's emitted by `applyCollection`, the whole-state\ncache write) — it does **not** fire from pack/chest/joker/claim calls, since\nthose patch resource balances rather than the `Collection` state slice\ndirectly. Re-`getUserState()` after those calls (or after a trade) if you need\nthe cached collection progress to reflect the change.\n\n```ts\nconst off = client.on(\"collection:packOpened\", (r) => {\n for (const c of r.GrantedCollectibles ?? []) console.log(c.CollectibleID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the album and open a pack\n\n```ts\nawait client.collection.getDefinitions();\nawait client.collection.getUserState();\n\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\nconst owned = client.data.user.state?.Collection?.OwnedCollectibles ?? {};\n\nconst pack = await client.collection.openPack(\"main-collection\", \"starter\");\nif (!pack.ok) return showError(pack.error); // e.g. insufficient currency/items\nfor (const c of pack.data.GrantedCollectibles ?? []) {\n // new sticker; check pack.data.DuplicateCollectibles for ones already owned\n}\nif (pack.data.CollectionJustCompleted) showGrandPrizeAvailable();\nfor (const setID of pack.data.NewlyCompletedSetIDs ?? [])\n showSetComplete(setID);\n\n// Balances (pack Cost debited, CollectionCurrencyEarned credited from\n// duplicate conversion) are already reflected here:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\n```\n\n### Spend Collection Currency on a pity chest, then a Joker\n\n```ts\nconst chest = await client.collection.openCollectionChest(\n \"main-collection\",\n \"silver-chest\",\n);\nif (!chest.ok) return showError(chest.error);\n// chest.data.NewCollectionCurrencyBalance reflects the debit; TriggeredPity\n// lists any pity rule(s) that fired on this open.\n\n// Jokers are a regular item (CollectibleJokerItemID in Definitions) burned\n// to grant one specific missing Collectible. Special versions can't be\n// targeted this way — pass collectibleIsSpecial via a Special-only Collectible\n// and it's rejected: \"CollectibleJoker cannot be used for Special Collectibles.\"\nconst joker = await client.collection.useCollectibleJoker(\n \"main-collection\",\n \"card-042\",\n);\nif (!joker.ok) return showError(joker.error); // e.g. \"already owned\", no joker item\nif (joker.data.CollectionJustCompleted) showGrandPrizeAvailable();\n```\n\n### Claim set rewards, then the Grand Prize\n\n```ts\nconst setClaim = await client.collection.claimSetReward(\n \"main-collection\",\n \"set-forest\",\n);\nif (!setClaim.ok) return showError(setClaim.error); // e.g. \"set not completed\", \"already claimed\"\n\nif (client.data.user.state?.Collection?.IsCollectionCompleted) {\n const grand = await client.collection.claimGrandPrize(\"main-collection\");\n if (!grand.ok) return showError(grand.error); // e.g. \"already claimed\"\n}\n```\n\n### Batch-claim several completed sets\n\n```ts\nconst res = await client.collection.claimSetRewardsBatch([\n { CollectionID: \"main-collection\", SetID: \"set-forest\" },\n { CollectionID: \"main-collection\", SetID: \"set-ocean\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) markClaimed(item.Id);\n else showItemError(item.Id, item.Error); // e.g. that set wasn't complete\n}\n```\n\nBatch results are **partial-aware**: `res.ok` says the call ran; each\nelement's `Success`/`Error` says whether that specific set's reward applied.\nOnly the **first** successful item's `Resources` is applied to the cache by\nthe SDK (a `resourcesApplied` guard short-circuits after the first) — if you\nneed every claimed set's grant reflected precisely, re-fetch balances (e.g.\n`client.user`/inventory refresh) after a multi-set batch rather than trusting\nthe cache to have summed them all.\n\n### Trade Collectibles peer-to-peer\n\n```ts\n// Offer my duplicate for a specific card back. Receiver must already be a friend\n// (see social-system) — the server rejects otherwise.\nconst sent = await client.collection.sendTradeOffer(\n \"main-collection\",\n \"card-011\", // CollectibleID I'm giving\n false, // not a Special version\n \"u2\", // receiver\n \"card-042\", // requested back (optional — omit for an open/gift offer)\n);\nif (!sent.ok) return showError(sent.error); // e.g. daily trade limit reached, don't own it\n\n// Receiver's side:\nconst incoming =\n await client.collection.getIncomingTradeOffers(\"main-collection\");\nfor (const offer of incoming.data?.Offers ?? []) {\n // offer.Status === \"Pending\" -> show Accept/Decline\n}\nconst accept = await client.collection.acceptTradeOffer(offer.OfferID);\nif (!accept.ok) return showError(accept.error); // e.g. offer expired, requested card no longer owned\naccept.data.ReceivedCollectibleID; // what I got\naccept.data.SentCollectibleID; // what I gave up\n\n// Sender can cancel while still Pending:\nawait client.collection.cancelTradeOffer(sent.data.OfferID);\n```\n\nRules enforced server-side, not client-side — surface the `error` string, don't\npre-validate:\n\n- **Receiver must be a friend.** `sendTradeOffer` rejects with \"Receiver must be\n in your friends list\" otherwise (see [social-system](../social-system/SKILL.md)\n to add them first).\n- **You need a spare copy to offer or request one back.** A normal Collectible\n needs `OwnedCollectibles[id] >= 2` to be offered or requested (one copy stays\n with you); a Special needs only `>= 1` (it moves entirely, no copy kept\n behind). Rejections read \"You need a duplicate (count >= 2) to trade this\n Collectible.\" / \"You don't own this Special Collectible.\"\n- **The receiver's inbox caps at 10 pending offers**; sending past that fails\n with \"Receiver has too many pending trade offers.\"\n- **Offers expire after 7 days** (168h) from creation — `ExpiresAtUtc` on the\n response and on `CollectionTradeOfferDocument`; `acceptTradeOffer` past that\n point fails with \"Offer has expired.\" Nothing ever flips the stored `Status`\n to `\"Expired\"` server-side, though — `getIncomingTradeOffers` just filters\n lapsed offers out of the list, while `getMyTradeOffers` keeps returning them\n as `Status: \"Pending\"` with a stale `ExpiresAtUtc`. Compare `ExpiresAtUtc`\n to now yourself when rendering your own sent-offers list.\n- Special-version Collectibles can normally only be traded during a\n `SpecialTradeEventDefinition` window (`AllowedSpecialCollectibleIDs`,\n `SpecialTradeEventDailyTradeLimit`) — offering **or being asked for** a\n Special outside that window is rejected server-side on both `sendTradeOffer`\n and `acceptTradeOffer`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key —\n two separate calls are two real operations. A double-clicked \"Open Pack\"\n can open (and charge) twice. Disable the control while a call is in\n flight; the 600ms default throttle window rejects same-endpoint spam with\n `reason: \"throttled\"` but isn't a substitute for disabling the button.\n- **`user:collectionUpdated` is a state-replace signal, not a delta signal.**\n It only fires from `getUserState()`. Don't wire \"refresh the album UI\" to\n it and expect pack/chest/claim calls to trigger it — listen to the\n specific action events (`collection:packOpened`, etc.) instead, or\n re-`getUserState()` after mutating actions if you need the state slice\n itself refreshed.\n- **Trade offers never touch resources or the `Collection` cache slice.**\n There's no automatic balance/inventory update from send/cancel/accept/\n decline — re-fetch `getUserState()` (and re-list offers) to see the\n post-trade picture.\n- **`claimSetRewardsBatch` only applies the first successful item's\n `Resources` to the cache.** If the batch claims multiple sets, don't assume\n the cached currency/item balances reflect all of them — verify against a\n fresh state fetch if the UI shows exact totals.\n- **`UserCollectionState` is loosely typed (passthrough over `{}`).** Unlike\n `CollectionDefinitions` (strictly typed), the per-player state interface is\n a best-effort shape — treat documented fields as likely-present, not\n guaranteed, and code defensively.\n- **Duplicates aren't wasted — they convert to Collection Currency** per\n `DuplicateConversions` (rate keyed by rarity), which is what funds\n `openCollectionChest`. `OpenPackResponse.DuplicateCollectibles` lists which\n pulls were duplicates and `CollectionCurrencyEarned` is the resulting\n credit for that pack.\n- **A season-linked collection can wipe out from under you.** If a\n `CollectionDefinition` has `SeasonChainID` set, the backend resets the\n player's entire `Collection` state (owned Collectibles, currency, claimed\n sets, pity, everything) the moment the linked season rolls over — lazily, on\n the next call that touches Collection. There's no client-side warning event\n for this; just always render from a fresh `getUserState()` rather than\n assuming yesterday's cache is still valid across a session boundary.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the pack/chest reward-slot and pity-rule shape, the trade-offer\ndocument lifecycle, and the joker/duplicate-conversion mechanics.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",