@idosgames/mcp 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +6 -6
- package/registry/index.json +95 -23
- package/registry/modules/board-game.json +2 -2
- package/registry/modules/game-hud.json +2 -2
- package/registry/modules/idle-rpg.json +2 -2
- package/registry/modules/voxelcraft.json +1399 -79
- package/registry/modules/workshop.json +66 -0
- package/registry/skills/ai-generation-system.json +1 -1
- package/registry/skills/analytics-events.json +6 -0
- package/registry/skills/cloud-code.json +2 -2
- package/registry/skills/data-collections.json +6 -0
- package/registry/skills/experiments-system.json +6 -0
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/voxelcraft-worlds.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "workshop",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Workshop",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Workshop: players and the publisher share content (maps, levels, skins, models) — free, for in-game resources, or unlocked by holding items.",
|
|
11
|
+
"description": "A ready catalog of user-generated content for ANY game. Players publish what they made (a world, a level, a skin, any file the title allows), set how others get it — free, a price in in-game resources (several options at once, the author receives it minus commission), or 'hold N of this item / currency to unlock' (nothing is charged; while held or once) — and others browse, filter, acquire, open, like, favorite, follow authors and report. The publisher configures content types, formats, limits, commission and moderation in the Workshop section of the title config, curates featured collections and publishes official content from the dashboard. A game plugs its own content in by registering a handler with ctx.content.registerType({ type, label, listLocal, capture, open, modeId }) — the Workshop never imports the game. VoxelCraft registers 'voxelcraft.world'.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"player-made content catalog (maps, levels, skins, models, any file type the title allows)",
|
|
14
|
+
"publish a map or level made in the game",
|
|
15
|
+
"sell content for in-game resources with author royalties minus commission",
|
|
16
|
+
"free content sharing",
|
|
17
|
+
"unlock content by holding an item or currency without spending it",
|
|
18
|
+
"likes, favorites and following content creators",
|
|
19
|
+
"report content and auto-hide after reports",
|
|
20
|
+
"publisher featured collections and official content",
|
|
21
|
+
"download and open shared content in the game"
|
|
22
|
+
],
|
|
23
|
+
"tags": [
|
|
24
|
+
"workshop",
|
|
25
|
+
"ugc",
|
|
26
|
+
"user-generated-content",
|
|
27
|
+
"maps",
|
|
28
|
+
"levels",
|
|
29
|
+
"mods",
|
|
30
|
+
"sharing",
|
|
31
|
+
"marketplace",
|
|
32
|
+
"creators",
|
|
33
|
+
"social"
|
|
34
|
+
],
|
|
35
|
+
"author": {
|
|
36
|
+
"name": "iDos Games",
|
|
37
|
+
"url": "https://idosgames.com"
|
|
38
|
+
},
|
|
39
|
+
"version": "0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@idosgames/core": "0.14.1",
|
|
43
|
+
"@idosgames/module-sdk": "0.3.1",
|
|
44
|
+
"@idosgames/react": "0.2.7",
|
|
45
|
+
"react": "19.2.7"
|
|
46
|
+
},
|
|
47
|
+
"contentHash": "9b0128630509059fdfff91598bdb2e8ab7accc1fba452358caac04248d8165ba",
|
|
48
|
+
"files": [
|
|
49
|
+
{
|
|
50
|
+
"path": "index.ts",
|
|
51
|
+
"content": "// @idosgames/mod-workshop — the Workshop: player & publisher content and licenses, for any game.\n//\n// Primary export is the module manifest; the React screen is exported too so a project can place it\n// elsewhere (a modal, a tab of its own menu) after copying this into src/modules/.\n\nexport { workshopModule } from \"./module\";\nexport {\n makeWorkshopPanel,\n WorkshopApp,\n type WorkshopHost,\n} from \"./WorkshopApp\";\n"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "module.ts",
|
|
55
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeWorkshopPanel } from \"./WorkshopApp\";\n\n// The Workshop feature module: a route of its own (\"Workshop\") with one full-screen panel and no scene.\n// It works for any game — what a content type IS (how to list, capture and open it) comes from the\n// handlers games register in `ctx.content`; the Workshop never imports a game.\nexport const workshopModule: Module = defineModule({\n id: \"workshop\",\n meta: {\n name: \"Workshop\",\n type: \"app\",\n engine: \"dom\",\n },\n setup(ctx) {\n ctx.registerRoute({\n id: \"workshop\",\n label: \"Workshop\",\n icon: \"🛠️\",\n order: 90,\n });\n ctx.registerPanel({\n id: \"workshop\",\n slot: \"overlay\",\n component: makeWorkshopPanel({\n client: ctx.client,\n content: ctx.content,\n navigate: (modeId) => ctx.navigate(modeId),\n }),\n });\n\n ctx.exposeToAgent({\n state: () => ({\n contentTypes: ctx.content.listTypes().map((t) => ({\n type: t.type,\n canPublish: Boolean(t.listLocal && t.capture),\n canOpen: Boolean(t.open),\n })),\n }),\n describeActions: {},\n });\n },\n});\n"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "WorkshopApp.tsx",
|
|
59
|
+
"content": "import {\n useCallback,\n useEffect,\n useState,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n WorkshopAccessOption,\n WorkshopAccessOptionInput,\n WorkshopBrowseQuery,\n WorkshopContentResponse,\n WorkshopContentView,\n WorkshopDefinitionsResponse,\n WorkshopReportReason,\n WorkshopVisibility,\n} from \"@idosgames/core\";\nimport type {\n ContentRegistry,\n ContentTypeHandler,\n IDosGamesClient,\n} from \"@idosgames/module-sdk\";\n\n// The Workshop screen: catalog, content card, publish wizard, \"mine / licenses / favorites\".\n//\n// It is the same for every game. Everything game-specific comes from two places: the title's Workshop\n// config (which types exist, formats, access modes — read via client.workshop.getDefinitions()) and the\n// handlers games register in ctx.content (how to list, capture and open their content). This file is\n// plain editable React with inline styles — restyle or replace it like any other module source.\n\n/** What the panel needs from the host — the module passes it in at setup(). */\nexport interface WorkshopHost {\n client: IDosGamesClient;\n content: ContentRegistry;\n navigate(modeId: string): void;\n}\n\nexport function makeWorkshopPanel(host: WorkshopHost): ComponentType {\n return function WorkshopPanel(): ReactNode {\n return <WorkshopApp host={host} />;\n };\n}\n\ntype Tab = \"catalog\" | \"publish\" | \"mine\" | \"licenses\" | \"favorites\";\n\nconst TABS: Array<[Tab, string]> = [\n [\"catalog\", \"Catalog\"],\n [\"publish\", \"Publish\"],\n [\"mine\", \"My content\"],\n [\"licenses\", \"My library\"],\n [\"favorites\", \"Favorites\"],\n];\n\n/** Server error codes → words. Anything else is shown as is (it is already a sentence). */\nconst ERRORS: Record<string, string> = {\n WORKSHOP_DISABLED: \"The Workshop is turned off.\",\n WORKSHOP_NOT_AVAILABLE: \"The Workshop is not available to you.\",\n WORKSHOP_CLOSED: \"The Workshop is closed right now.\",\n PRICE_CHANGED: \"The author changed the price — look at it again.\",\n HOLDING_REQUIREMENTS_NOT_MET: \"You don't hold what this option requires.\",\n ACCESS_DENIED: \"You don't have access to this yet.\",\n CONTENT_NOT_FOUND: \"Not found (or not shared with you).\",\n CONTENT_NOT_AVAILABLE: \"This publication is not available now.\",\n CONTENT_TEXT_REJECTED:\n \"The title or description contains words that are not allowed.\",\n PUBLISH_LIMIT_REACHED:\n \"You have reached the limit of publications of this type.\",\n DAILY_PUBLISH_LIMIT_REACHED: \"You have reached today's publishing limit.\",\n PUBLISH_NOT_ALLOWED: \"You can't publish this type of content yet.\",\n CANNOT_LIKE_OWN_CONTENT: \"You can't like your own publication.\",\n WORKSHOP_STORAGE_NOT_CONFIGURED:\n \"File storage is not configured for this game.\",\n TOO_MANY_PENDING_UPLOADS:\n \"You have too many unfinished uploads — try again a bit later.\",\n ACCESS_REQUIRED: \"Add at least one way for others to get it.\",\n OPTION_REQUIRED: \"Choose how you want to get it.\",\n PRICE_NO_LONGER_ALLOWED: \"This price is no longer allowed in this game.\",\n TITLE_REQUIRED: \"Give it a title.\",\n TITLE_TOO_LONG: \"The title is too long.\",\n THUMBNAIL_REQUIRED: \"This type of content needs a preview image.\",\n FILE_TOO_LARGE: \"The file is too large for this type of content.\",\n FILE_TYPE_MISMATCH: \"The file does not match its declared format.\",\n CONTENT_TYPE_NOT_ALLOWED:\n \"This type of content can't be published in this game.\",\n CANNOT_REPORT_OWN_CONTENT: \"You can't report your own publication.\",\n CANNOT_FOLLOW_SELF: \"You can't follow yourself.\",\n};\nconst errorText = (e: string): string => ERRORS[e] ?? e;\n\nexport function WorkshopApp({ host }: { host: WorkshopHost }): ReactNode {\n const [defs, setDefs] = useState<WorkshopDefinitionsResponse | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [tab, setTab] = useState<Tab>(\"catalog\");\n const [openID, setOpenID] = useState<string | null>(null);\n\n useEffect(() => {\n let alive = true;\n void host.client.workshop.getDefinitions().then((r) => {\n if (!alive) return;\n if (r.ok) setDefs(r.data);\n else setError(errorText(r.error));\n });\n return () => {\n alive = false;\n };\n }, [host]);\n\n if (!defs) return <Frame>{error ?? \"Loading the Workshop…\"}</Frame>;\n if (!defs.Definitions?.Enabled || !defs.GatePassed)\n return <Frame>The Workshop is not available in this game right now.</Frame>;\n\n return (\n <Frame>\n <div style={tabsRow}>\n {TABS.map(([id, label]) => (\n <button\n key={id}\n style={id === tab ? tabActive : tabBtn}\n onClick={() => setTab(id)}\n >\n {label}\n </button>\n ))}\n </div>\n {tab === \"catalog\" && (\n <Catalog host={host} defs={defs} onOpen={setOpenID} />\n )}\n {tab === \"publish\" && (\n <PublishWizard\n host={host}\n defs={defs}\n onDone={(id) => {\n setTab(\"mine\");\n setOpenID(id);\n }}\n />\n )}\n {tab === \"mine\" && <MyList host={host} kind=\"mine\" onOpen={setOpenID} />}\n {tab === \"licenses\" && (\n <MyList host={host} kind=\"licenses\" onOpen={setOpenID} />\n )}\n {tab === \"favorites\" && (\n <MyList host={host} kind=\"favorites\" onOpen={setOpenID} />\n )}\n {openID && (\n <ContentDetails\n host={host}\n contentID={openID}\n onClose={() => setOpenID(null)}\n />\n )}\n </Frame>\n );\n}\n\n// ────────────────────────────── Catalog ──────────────────────────────\n\nfunction Catalog({\n host,\n defs,\n onOpen,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onOpen(id: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content);\n const [query, setQuery] = useState<WorkshopBrowseQuery>({ sort: \"New\" });\n const [collection, setCollection] = useState<string | null>(null);\n const [items, setItems] = useState<WorkshopContentView[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const load = useCallback(\n async (more: boolean) => {\n setBusy(true);\n setError(null);\n if (collection) {\n const r = await host.client.workshop.getCollection(collection);\n if (r.ok) {\n setItems(r.data.Items ?? []);\n setCursor(null);\n } else setError(errorText(r.error));\n } else {\n const r = await host.client.workshop.browse({\n ...query,\n pageSize: 24,\n continuationToken: more ? (cursor ?? undefined) : undefined,\n });\n if (r.ok) {\n setItems((prev) =>\n more ? [...prev, ...(r.data.Items ?? [])] : (r.data.Items ?? []),\n );\n setCursor(r.data.ContinuationToken ?? null);\n } else setError(errorText(r.error));\n }\n setBusy(false);\n },\n [host, query, collection, cursor],\n );\n\n // Reload from the first page whenever the filter changes (not when the cursor does).\n useEffect(() => {\n void load(false);\n }, [query, collection]);\n\n const types = Object.entries(defs.Definitions?.ContentTypes ?? {}).filter(\n ([, t]) => t.Enabled !== false,\n );\n\n return (\n <div>\n {(defs.Collections?.length ?? 0) > 0 && (\n <div style={chipsRow}>\n <button\n style={collection === null ? chipActive : chip}\n onClick={() => setCollection(null)}\n >\n All\n </button>\n {defs.Collections?.map((c) => (\n <button\n key={c.CollectionID ?? \"\"}\n style={collection === c.CollectionID ? chipActive : chip}\n onClick={() => setCollection(c.CollectionID ?? null)}\n >\n ★ {c.Name ?? c.CollectionID}\n </button>\n ))}\n </div>\n )}\n {!collection && (\n <div style={filtersRow}>\n <select\n style={input}\n value={query.contentType ?? \"\"}\n onChange={(e) =>\n setQuery({ ...query, contentType: e.target.value || undefined })\n }\n >\n <option value=\"\">All types</option>\n {types.map(([id, t]) => (\n <option key={id} value={id}>\n {handlers.find((h) => h.type === id)?.label ??\n t.DisplayName ??\n id}\n </option>\n ))}\n </select>\n <select\n style={input}\n value={query.sort ?? \"New\"}\n onChange={(e) =>\n setQuery({\n ...query,\n sort: e.target.value as WorkshopBrowseQuery[\"sort\"],\n })\n }\n >\n <option value=\"New\">Newest</option>\n <option value=\"Popular\">Most acquired</option>\n <option value=\"MostLiked\">Most liked</option>\n </select>\n <input\n style={input}\n placeholder=\"Tag\"\n defaultValue={query.tag ?? \"\"}\n onKeyDown={(e) => {\n if (e.key === \"Enter\")\n setQuery({\n ...query,\n tag: (e.target as HTMLInputElement).value.trim() || undefined,\n });\n }}\n />\n <label style={checkLabel}>\n <input\n type=\"checkbox\"\n checked={query.fromFollowing === true}\n onChange={(e) =>\n setQuery({\n ...query,\n fromFollowing: e.target.checked || undefined,\n })\n }\n />\n From authors I follow\n </label>\n </div>\n )}\n {error && <div style={errorBox}>{error}</div>}\n {items.length === 0 && !busy && (\n <div style={muted}>Nothing here yet.</div>\n )}\n <div style={grid}>\n {items.map((item) => (\n <ContentCard\n key={item.ContentID ?? \"\"}\n item={item}\n handlers={handlers}\n onClick={() => item.ContentID && onOpen(item.ContentID)}\n />\n ))}\n </div>\n {cursor && (\n <button\n style={secondaryBtn}\n disabled={busy}\n onClick={() => void load(true)}\n >\n {busy ? \"Loading…\" : \"Show more\"}\n </button>\n )}\n </div>\n );\n}\n\nfunction ContentCard({\n item,\n handlers,\n onClick,\n}: {\n item: WorkshopContentView;\n handlers: ContentTypeHandler[];\n onClick(): void;\n}): ReactNode {\n const handler = handlers.find((h) => h.type === item.ContentType);\n return (\n <button style={card} onClick={onClick}>\n <div style={thumbBox}>\n {item.ThumbnailUrl ? (\n <img src={item.ThumbnailUrl} alt=\"\" style={thumbImg} />\n ) : (\n <span style={{ fontSize: 34 }}>{handler?.icon ?? \"📦\"}</span>\n )}\n </div>\n <div style={cardTitle}>{item.Title}</div>\n <div style={cardMeta}>\n {item.IsOfficial\n ? \"Official\"\n : (item.CreatorPublicData?.Username ?? \"Player\")}{\" \"}\n · ♥ {item.Stats?.Likes ?? 0}\n </div>\n <div style={badgesRow}>\n {item.Owned ? (\n <span style={badgeOwned}>In library</span>\n ) : (\n (item.Access ?? []).map((o) => (\n <span key={o.OptionID ?? \"\"} style={badge}>\n {accessLabel(o)}\n </span>\n ))\n )}\n {item.Status && item.Status !== \"Published\" && (\n <span style={badgeWarn}>{item.Status}</span>\n )}\n </div>\n </button>\n );\n}\n\n// ────────────────────────────── Details ──────────────────────────────\n\nconst REPORT_REASONS: WorkshopReportReason[] = [\n \"Inappropriate\",\n \"Spam\",\n \"Stolen\",\n \"Broken\",\n \"Other\",\n];\n\nfunction ContentDetails({\n host,\n contentID,\n onClose,\n}: {\n host: WorkshopHost;\n contentID: string;\n onClose(): void;\n}): ReactNode {\n const [data, setData] = useState<WorkshopContentResponse | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [message, setMessage] = useState<string | null>(null);\n const [following, setFollowing] = useState<boolean | null>(null);\n const [reportReason, setReportReason] =\n useState<WorkshopReportReason>(\"Inappropriate\");\n\n const reload = useCallback(async () => {\n const r = await host.client.workshop.getContent(contentID);\n if (r.ok) setData(r.data);\n else setMessage(errorText(r.error));\n }, [host, contentID]);\n\n useEffect(() => {\n void reload();\n }, [reload]);\n\n const view = data?.Content;\n const handler = view?.ContentType\n ? host.content.getType(view.ContentType)\n : undefined;\n\n const run = async (\n label: string,\n work: () => Promise<string | null>,\n ): Promise<void> => {\n setBusy(label);\n setMessage(null);\n try {\n setMessage(await work());\n } catch (e) {\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(null);\n }\n };\n\n const open = (): Promise<void> =>\n run(\"open\", async () => {\n if (!view?.ContentID) return null;\n if (!handler?.open)\n return \"This game can't open this type of content here.\";\n const r = await host.client.workshop.downloadFiles(view.ContentID);\n if (!r.ok) return errorText(r.error);\n await handler.open({\n contentID: view.ContentID,\n title: view.Title,\n revision: view.Revision,\n files: r.data.files,\n });\n if (handler.modeId) host.navigate(handler.modeId);\n return null;\n });\n\n const acquire = (option: WorkshopAccessOption): Promise<void> =>\n run(`get:${option.OptionID}`, async () => {\n const r = await host.client.workshop.acquire(contentID, option);\n if (!r.ok) return errorText(r.error);\n await reload();\n return r.data.AlreadyOwned\n ? \"Already in your library.\"\n : \"Added to your library.\";\n });\n\n if (!view) return <Modal onClose={onClose}>{message ?? \"Loading…\"}</Modal>;\n\n const states = new Map(\n (data?.AccessState ?? []).map((s) => [\n s.OptionID ?? \"\",\n s.Available !== false,\n ]),\n );\n const canOpen = Boolean(data?.CanDownload);\n\n return (\n <Modal onClose={onClose}>\n <div style={detailsHead}>\n <div style={detailsThumb}>\n {view.ThumbnailUrl ? (\n <img src={view.ThumbnailUrl} alt=\"\" style={thumbImg} />\n ) : (\n <span style={{ fontSize: 48 }}>{handler?.icon ?? \"📦\"}</span>\n )}\n </div>\n <div style={{ minWidth: 0 }}>\n <h2 style={{ margin: \"0 0 6px\" }}>{view.Title}</h2>\n <div style={muted}>\n {view.IsOfficial\n ? \"Official content\"\n : `by ${view.CreatorPublicData?.Username ?? \"a player\"}`}{\" \"}\n · {handler?.label ?? view.ContentType} · rev. {view.Revision}\n </div>\n <div style={muted}>\n ♥ {view.Stats?.Likes ?? 0} · ★ {view.Stats?.Favorites ?? 0} · ⬇{\" \"}\n {view.Stats?.Acquisitions ?? 0}\n {view.TotalBytes ? ` · ${formatBytes(view.TotalBytes)}` : \"\"}\n </div>\n {(view.Tags?.length ?? 0) > 0 && (\n <div style={badgesRow}>\n {view.Tags?.map((t) => (\n <span key={t} style={badge}>\n #{t}\n </span>\n ))}\n </div>\n )}\n </div>\n </div>\n\n {view.Description && (\n <p style={{ whiteSpace: \"pre-wrap\" }}>{view.Description}</p>\n )}\n {view.IsMine && view.ModerationNote && (\n <div style={errorBox}>Moderation: {view.ModerationNote}</div>\n )}\n\n <div style={actionsCol}>\n {canOpen ? (\n <button\n style={primaryBtn}\n disabled={busy !== null || !handler?.open}\n onClick={() => void open()}\n >\n {busy === \"open\"\n ? \"Opening…\"\n : handler?.open\n ? \"Open\"\n : \"Owned (open it in the game)\"}\n </button>\n ) : (\n (view.Access ?? []).map((o) => {\n const available = states.get(o.OptionID ?? \"\") ?? true;\n return (\n <button\n key={o.OptionID ?? \"\"}\n style={available ? primaryBtn : disabledBtn}\n disabled={!available || busy !== null}\n title={\n available\n ? undefined\n : \"You don't meet this option's requirements\"\n }\n onClick={() => void acquire(o)}\n >\n {busy === `get:${o.OptionID}` ? \"…\" : `Get — ${accessLabel(o)}`}\n </button>\n );\n })\n )}\n </div>\n\n <div style={filtersRow}>\n {!view.IsMine && (\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"like\", async () => {\n const r = view.Liked\n ? await host.client.workshop.unlike(contentID)\n : await host.client.workshop.like(contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return null;\n })\n }\n >\n {view.Liked ? \"♥ Liked\" : \"♡ Like\"}\n </button>\n )}\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"fav\", async () => {\n const r = view.Favorited\n ? await host.client.workshop.unfavorite(contentID)\n : await host.client.workshop.favorite(contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return null;\n })\n }\n >\n {view.Favorited ? \"★ In favorites\" : \"☆ Favorite\"}\n </button>\n {!view.IsMine && view.CreatorUserID && (\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"follow\", async () => {\n const creator = view.CreatorUserID ?? \"\";\n const r = following\n ? await host.client.workshop.unfollow(creator)\n : await host.client.workshop.follow(creator);\n if (!r.ok) return errorText(r.error);\n setFollowing(r.data.Following === true);\n return r.data.Following ? \"You follow this author.\" : null;\n })\n }\n >\n {following ? \"Following ✓\" : \"Follow author\"}\n </button>\n )}\n {view.IsMine && view.Status !== \"Removed\" && (\n <button\n style={dangerBtn}\n disabled={busy !== null}\n onClick={() => {\n if (\n !confirm(\n \"Unpublish for good? Players who already have it keep it.\",\n )\n )\n return;\n void run(\"unpublish\", async () => {\n const r = await host.client.workshop.unpublish(contentID);\n if (!r.ok) return errorText(r.error);\n await reload();\n return \"Unpublished.\";\n });\n }}\n >\n Unpublish\n </button>\n )}\n </div>\n\n {!view.IsMine && (\n <div style={filtersRow}>\n <select\n style={input}\n value={reportReason}\n onChange={(e) =>\n setReportReason(e.target.value as WorkshopReportReason)\n }\n >\n {REPORT_REASONS.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n <button\n style={secondaryBtn}\n disabled={busy !== null}\n onClick={() =>\n void run(\"report\", async () => {\n const r = await host.client.workshop.report(\n contentID,\n reportReason,\n );\n if (!r.ok) return errorText(r.error);\n return r.data.AlreadyReported\n ? \"You already reported this.\"\n : \"Thanks — the publisher will review it.\";\n })\n }\n >\n Report\n </button>\n </div>\n )}\n\n {message && <div style={noticeBox}>{message}</div>}\n </Modal>\n );\n}\n\n// ────────────────────────────── Publish ──────────────────────────────\n\ninterface AccessDraft {\n mode: \"Free\" | \"Price\" | \"Holding\";\n resource: string; // \"vc:GOLD\" | \"item:<catalog>/<item>\"\n amount: number;\n holdMode: \"WhileHeld\" | \"UnlockOnce\";\n}\n\nfunction PublishWizard({\n host,\n defs,\n onDone,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onDone(contentID: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content).filter(\n (h) => h.listLocal && h.capture && defs.CanPublish?.[h.type] === true,\n );\n const [type, setType] = useState<string | null>(null);\n const [locals, setLocals] = useState<Array<{\n id: string;\n name: string;\n subtitle?: string;\n }> | null>(null);\n const [localId, setLocalId] = useState<string | null>(null);\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [tags, setTags] = useState(\"\");\n const [visibility, setVisibility] = useState<WorkshopVisibility>(\"Public\");\n const [access, setAccess] = useState<AccessDraft[]>([\n { mode: \"Free\", resource: \"\", amount: 1, holdMode: \"UnlockOnce\" },\n ]);\n const [busy, setBusy] = useState(false);\n const [message, setMessage] = useState<string | null>(null);\n\n const handler = handlers.find((h) => h.type === type);\n const typeDef = type ? defs.Definitions?.ContentTypes?.[type] : undefined;\n const allowedModes = typeDef?.AllowedAccessModes?.length\n ? typeDef.AllowedAccessModes\n : [\"Free\", \"Price\", \"Holding\"];\n const resources = useResourceOptions(host.client);\n // Плата за публикацию берётся только ресурсами: вариант с оплатой в сторе сервер отвергнет.\n const fee = Object.entries(typeDef?.PublishFeeOptions ?? {}).find(\n ([, option]) =>\n !(option?.Cost?.Standard?.Entries ?? []).some(\n (e) => e?.Type === \"Purchase\",\n ),\n );\n\n useEffect(() => {\n setLocals(null);\n setLocalId(null);\n if (!handler?.listLocal) return;\n void handler\n .listLocal()\n .then(setLocals, (e: unknown) =>\n setMessage(e instanceof Error ? e.message : String(e)),\n );\n }, [handler]);\n\n if (handlers.length === 0)\n return (\n <div style={muted}>\n Nothing to publish from here: the games in this project either do not\n register their content, or you can't publish it yet.\n </div>\n );\n\n const publish = async (): Promise<void> => {\n if (!handler?.capture || !type || !localId) return;\n // Опция без ресурса молча выпала бы из списка, и публикация ушла бы не с теми способами получить.\n if (access.some((a) => a.mode !== \"Free\" && !a.resource)) {\n setMessage(\"Pick a resource for every paid or hold-to-unlock option.\");\n return;\n }\n setBusy(true);\n setMessage(null);\n try {\n const captured = await handler.capture(localId);\n const r = await host.client.workshop.publish({\n contentType: type,\n files: captured.files,\n thumbnail: captured.thumbnail,\n title: title.trim() || captured.suggestedTitle || \"Untitled\",\n description: description.trim() || captured.suggestedDescription,\n tags: tags\n .split(\",\")\n .map((t) => t.trim())\n .filter(Boolean)\n .concat(captured.tags ?? []),\n metadata: captured.metadata,\n visibility,\n access: access\n .map(toAccessInput)\n .filter((a): a is WorkshopAccessOptionInput => a !== null),\n selectedFeeOptionID: fee?.[0],\n });\n if (!r.ok) setMessage(errorText(r.error));\n else if (r.data.Content?.ContentID) onDone(r.data.Content.ContentID);\n } catch (e) {\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <div style={formCol}>\n <label style={fieldLabel}>What</label>\n <div style={chipsRow}>\n {handlers.map((h) => (\n <button\n key={h.type}\n style={type === h.type ? chipActive : chip}\n onClick={() => setType(h.type)}\n >\n {h.icon} {h.label}\n </button>\n ))}\n </div>\n\n {handler && (\n <>\n <label style={fieldLabel}>Which one</label>\n {!locals ? (\n <div style={muted}>Loading…</div>\n ) : locals.length === 0 ? (\n <div style={muted}>You have nothing of this type yet.</div>\n ) : (\n <div style={formCol}>\n {locals.map((l) => (\n <label key={l.id} style={checkLabel}>\n <input\n type=\"radio\"\n name=\"wsLocal\"\n checked={localId === l.id}\n onChange={() => {\n setLocalId(l.id);\n if (!title) setTitle(l.name);\n }}\n />\n {l.name}{\" \"}\n {l.subtitle && <span style={muted}>· {l.subtitle}</span>}\n </label>\n ))}\n </div>\n )}\n </>\n )}\n\n {localId && (\n <>\n <label style={fieldLabel}>Title</label>\n <input\n style={input}\n value={title}\n maxLength={defs.Definitions?.Limits?.MaxTitleLength ?? 60}\n onChange={(e) => setTitle(e.target.value)}\n />\n <label style={fieldLabel}>Description</label>\n <textarea\n style={{ ...input, minHeight: 70 }}\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n />\n <label style={fieldLabel}>\n Tags (comma separated\n {typeDef?.TagVocabulary?.length\n ? `: ${typeDef.TagVocabulary.join(\", \")}`\n : \"\"}\n )\n </label>\n <input\n style={input}\n value={tags}\n onChange={(e) => setTags(e.target.value)}\n />\n <label style={fieldLabel}>Who can see it</label>\n <select\n style={input}\n value={visibility}\n onChange={(e) =>\n setVisibility(e.target.value as WorkshopVisibility)\n }\n >\n <option value=\"Public\">Everyone (in the catalog)</option>\n <option value=\"Unlisted\">Anyone with the link</option>\n <option value=\"Friends\">Only my friends</option>\n </select>\n\n <label style={fieldLabel}>How others get it — any option works</label>\n {access.map((a, i) => (\n <div key={i} style={filtersRow}>\n <select\n style={input}\n value={a.mode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n mode: e.target.value as AccessDraft[\"mode\"],\n }),\n )\n }\n >\n {allowedModes.map((m) => (\n <option key={m} value={m}>\n {m === \"Free\"\n ? \"Free\"\n : m === \"Price\"\n ? \"For resources\"\n : \"Hold to unlock\"}\n </option>\n ))}\n </select>\n {a.mode !== \"Free\" && (\n <>\n <select\n style={input}\n value={a.resource}\n onChange={(e) =>\n setAccess(patch(access, i, { resource: e.target.value }))\n }\n >\n <option value=\"\">— resource —</option>\n {resources\n .filter(\n (r) => a.mode === \"Holding\" || r.startsWith(\"vc:\"),\n )\n .map((r) => (\n <option key={r} value={r}>\n {r.replace(/^vc:/, \"\").replace(/^item:/, \"item \")}\n </option>\n ))}\n </select>\n <input\n style={{ ...input, width: 90 }}\n type=\"number\"\n min={1}\n value={a.amount}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n amount: Math.max(1, Number(e.target.value) || 1),\n }),\n )\n }\n />\n </>\n )}\n {a.mode === \"Holding\" && (\n <select\n style={input}\n value={a.holdMode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n holdMode: e.target.value as AccessDraft[\"holdMode\"],\n }),\n )\n }\n >\n <option value=\"UnlockOnce\">Checked once, then forever</option>\n <option value=\"WhileHeld\">Only while they hold it</option>\n </select>\n )}\n {access.length > 1 && (\n <button\n style={secondaryBtn}\n onClick={() => setAccess(access.filter((_, j) => j !== i))}\n >\n ✕\n </button>\n )}\n </div>\n ))}\n <button\n style={secondaryBtn}\n onClick={() =>\n setAccess([\n ...access,\n {\n mode: allowedModes.includes(\"Price\") ? \"Price\" : \"Free\",\n resource: \"\",\n amount: 10,\n holdMode: \"UnlockOnce\",\n },\n ])\n }\n >\n + Add an option\n </button>\n\n {fee && (\n <div style={muted}>\n Publishing costs {bundleLabel(fee[1].Cost?.Standard)} (charged\n once).\n </div>\n )}\n <button\n style={primaryBtn}\n disabled={busy}\n onClick={() => void publish()}\n >\n {busy ? \"Publishing…\" : \"Publish\"}\n </button>\n </>\n )}\n {message && <div style={errorBox}>{message}</div>}\n </div>\n );\n}\n\nfunction patch<T>(list: T[], index: number, change: Partial<T>): T[] {\n return list.map((item, i) => (i === index ? { ...item, ...change } : item));\n}\n\nfunction toAccessInput(a: AccessDraft): WorkshopAccessOptionInput | null {\n if (a.mode === \"Free\") return { Mode: \"Free\" };\n if (!a.resource) return null;\n const [kind, id = \"\"] = a.resource.split(\":\", 2);\n if (a.mode === \"Price\") {\n return {\n Mode: \"Price\",\n Price: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount },\n ],\n },\n };\n }\n const [catalogID, itemID] = id.split(\"/\", 2);\n return {\n Mode: \"Holding\",\n Holding: {\n Mode: a.holdMode,\n Match: \"All\",\n Requirements: [\n kind === \"vc\"\n ? { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount }\n : {\n Type: \"Item\",\n CatalogID: catalogID,\n ItemID: itemID ?? \"\",\n Amount: a.amount,\n },\n ],\n },\n };\n}\n\n// ────────────────────────────── Lists ──────────────────────────────\n\nfunction MyList({\n host,\n kind,\n onOpen,\n}: {\n host: WorkshopHost;\n kind: \"mine\" | \"licenses\" | \"favorites\";\n onOpen(id: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content);\n const [items, setItems] = useState<WorkshopContentView[] | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n useEffect(() => {\n let alive = true;\n const load = async (): Promise<void> => {\n const w = host.client.workshop;\n if (kind === \"licenses\") {\n const r = await w.getMyLicenses();\n if (!alive) return;\n if (r.ok)\n setItems(\n (r.data.Licenses ?? [])\n .map((l) => l.Content)\n .filter((c): c is WorkshopContentView => Boolean(c)),\n );\n else setError(errorText(r.error));\n return;\n }\n const r =\n kind === \"mine\" ? await w.getMyContent() : await w.getMyFavorites();\n if (!alive) return;\n if (r.ok) setItems(r.data.Items ?? []);\n else setError(errorText(r.error));\n };\n void load();\n return () => {\n alive = false;\n };\n }, [host, kind]);\n\n if (error) return <div style={errorBox}>{error}</div>;\n if (!items) return <div style={muted}>Loading…</div>;\n if (items.length === 0) return <div style={muted}>Empty.</div>;\n return (\n <div style={grid}>\n {items.map((item) => (\n <ContentCard\n key={item.ContentID ?? \"\"}\n item={item}\n handlers={handlers}\n onClick={() => item.ContentID && onOpen(item.ContentID)}\n />\n ))}\n </div>\n );\n}\n\n// ────────────────────────────── helpers ──────────────────────────────\n\n/**\n * Registered content types, re-read on every registry change. ⚠ Not keyed by the NUMBER of types: a game\n * re-installed after re-login registers the same type again with a new handler, the count stays the\n * same, and a count-based snapshot kept the handler of the destroyed game.\n */\nfunction useContentTypes(content: ContentRegistry): ContentTypeHandler[] {\n const [types, setTypes] = useState(() => content.listTypes());\n useEffect(() => {\n setTypes(content.listTypes());\n return content.subscribe(() => setTypes(content.listTypes()));\n }, [content]);\n return types;\n}\n\n/** \"vc:GOLD\" and \"item:<catalog>/<item>\" for every currency and item the title defines. */\nfunction useResourceOptions(client: IDosGamesClient): string[] {\n const [list] = useState(() => {\n const out: string[] = [];\n for (const id of Object.keys(\n client.data.config.currencyDefinitions?.VirtualCurrencies ?? {},\n ))\n out.push(`vc:${id}`);\n for (const [catalogID, catalog] of Object.entries(\n client.data.config.itemDefinitions?.Catalogs ?? {},\n ))\n for (const itemID of Object.keys(catalog?.Items ?? {}))\n out.push(`item:${catalogID}/${itemID}`);\n return out;\n });\n return list;\n}\n\ntype BundleLike =\n | {\n Entries?: Array<{\n CurrencyID?: string | null;\n ItemID?: string | null;\n Amount?: number | null;\n } | null> | null;\n }\n | null\n | undefined;\n\nfunction bundleLabel(bundle: BundleLike): string {\n const parts = (bundle?.Entries ?? [])\n .filter((e): e is NonNullable<typeof e> => Boolean(e))\n .map((e) => `${e.Amount ?? 0} ${e.CurrencyID ?? e.ItemID ?? \"\"}`.trim());\n return parts.length ? parts.join(\" + \") : \"free\";\n}\n\nfunction accessLabel(o: WorkshopAccessOption): string {\n if (o.Mode === \"Free\") return \"Free\";\n if (o.Mode === \"Price\") return bundleLabel(o.Price);\n const reqs = (o.Holding?.Requirements ?? []).map(\n (r) =>\n `${r.Amount ?? 0} ${r.CurrencyID ?? r.ItemID ?? \"\"}${r.MinLevel ? ` (lvl ${r.MinLevel}+)` : \"\"}`,\n );\n const joined = reqs.join(o.Holding?.Match === \"Any\" ? \" or \" : \" + \");\n return `🔑 hold ${joined}${o.Holding?.Mode === \"WhileHeld\" ? \" (while held)\" : \"\"}`;\n}\n\nfunction formatBytes(n: number): string {\n if (n < 1024) return `${n} B`;\n if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;\n return `${(n / 1024 / 1024).toFixed(1)} MB`;\n}\n\nfunction Frame({ children }: { children: ReactNode }): ReactNode {\n return <div style={frame}>{children}</div>;\n}\n\nfunction Modal({\n children,\n onClose,\n}: {\n children: ReactNode;\n onClose(): void;\n}): ReactNode {\n return (\n <div style={backdrop} onClick={onClose}>\n <div style={modal} onClick={(e) => e.stopPropagation()}>\n <button style={closeBtn} onClick={onClose} aria-label=\"Close\">\n ✕\n </button>\n {children}\n </div>\n </div>\n );\n}\n\n// ────────────────────────────── styles ──────────────────────────────\n\nconst frame: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n overflow: \"auto\",\n padding: \"16px 20px 40px\",\n background: \"#12151c\",\n color: \"#e8e8ea\",\n fontFamily: \"system-ui, sans-serif\",\n pointerEvents: \"auto\",\n};\nconst tabsRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n marginBottom: 14,\n};\nconst tabBtn: CSSProperties = {\n background: \"transparent\",\n color: \"#aab\",\n border: \"1px solid #2a2f3a\",\n borderRadius: 8,\n padding: \"6px 12px\",\n cursor: \"pointer\",\n};\nconst tabActive: CSSProperties = {\n ...tabBtn,\n background: \"#2d6cdf\",\n color: \"#fff\",\n borderColor: \"#2d6cdf\",\n};\nconst chipsRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n margin: \"8px 0\",\n};\nconst chip: CSSProperties = {\n ...tabBtn,\n borderRadius: 999,\n padding: \"4px 10px\",\n fontSize: 13,\n};\nconst chipActive: CSSProperties = {\n ...chip,\n background: \"#394a6b\",\n color: \"#fff\",\n};\nconst filtersRow: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n margin: \"8px 0\",\n};\nconst input: CSSProperties = {\n background: \"#1b2029\",\n color: \"#e8e8ea\",\n border: \"1px solid #2a2f3a\",\n borderRadius: 6,\n padding: \"6px 8px\",\n font: \"inherit\",\n};\nconst checkLabel: CSSProperties = {\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n fontSize: 14,\n};\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n};\nconst card: CSSProperties = {\n textAlign: \"left\",\n background: \"#1b2029\",\n border: \"1px solid #2a2f3a\",\n borderRadius: 10,\n padding: 8,\n color: \"inherit\",\n cursor: \"pointer\",\n font: \"inherit\",\n};\nconst thumbBox: CSSProperties = {\n aspectRatio: \"1 / 1\",\n borderRadius: 8,\n background: \"#0d1016\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n overflow: \"hidden\",\n};\nconst thumbImg: CSSProperties = {\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n imageRendering: \"pixelated\",\n};\nconst cardTitle: CSSProperties = {\n fontWeight: 600,\n margin: \"6px 0 2px\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n};\nconst cardMeta: CSSProperties = { fontSize: 12, color: \"#8a90a0\" };\nconst badgesRow: CSSProperties = {\n display: \"flex\",\n gap: 4,\n flexWrap: \"wrap\",\n marginTop: 6,\n};\nconst badge: CSSProperties = {\n fontSize: 11,\n background: \"#263043\",\n borderRadius: 4,\n padding: \"2px 6px\",\n};\nconst badgeOwned: CSSProperties = { ...badge, background: \"#1f5134\" };\nconst badgeWarn: CSSProperties = { ...badge, background: \"#6b4a1f\" };\nconst muted: CSSProperties = { color: \"#8a90a0\", fontSize: 13 };\nconst errorBox: CSSProperties = {\n background: \"#3a1d22\",\n color: \"#ffb4b4\",\n borderRadius: 6,\n padding: \"8px 10px\",\n margin: \"8px 0\",\n};\nconst noticeBox: CSSProperties = {\n background: \"#1d2c3a\",\n borderRadius: 6,\n padding: \"8px 10px\",\n marginTop: 10,\n};\nconst primaryBtn: CSSProperties = {\n background: \"#2d6cdf\",\n color: \"#fff\",\n border: 0,\n borderRadius: 8,\n padding: \"9px 14px\",\n cursor: \"pointer\",\n font: \"inherit\",\n fontWeight: 600,\n};\nconst disabledBtn: CSSProperties = {\n ...primaryBtn,\n background: \"#3a3f4a\",\n color: \"#99a\",\n cursor: \"not-allowed\",\n};\nconst secondaryBtn: CSSProperties = { ...tabBtn, color: \"#dde\" };\nconst dangerBtn: CSSProperties = {\n ...tabBtn,\n color: \"#ff9d9d\",\n borderColor: \"#5a2a30\",\n};\nconst actionsCol: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n margin: \"12px 0\",\n};\nconst formCol: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 6,\n maxWidth: 640,\n};\nconst fieldLabel: CSSProperties = { fontSize: 13, color: \"#aab\", marginTop: 8 };\nconst backdrop: CSSProperties = {\n position: \"fixed\",\n inset: 0,\n background: \"rgba(0,0,0,.6)\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n zIndex: 50,\n};\nconst modal: CSSProperties = {\n position: \"relative\",\n background: \"#171b23\",\n borderRadius: 12,\n padding: 20,\n width: \"min(640px, 94vw)\",\n maxHeight: \"90vh\",\n overflow: \"auto\",\n};\nconst closeBtn: CSSProperties = {\n position: \"absolute\",\n top: 10,\n right: 10,\n background: \"transparent\",\n color: \"#aab\",\n border: 0,\n fontSize: 18,\n cursor: \"pointer\",\n};\nconst detailsHead: CSSProperties = {\n display: \"flex\",\n gap: 14,\n alignItems: \"flex-start\",\n marginRight: 24,\n};\nconst detailsThumb: CSSProperties = {\n ...thumbBox,\n width: 120,\n flex: \"0 0 120px\",\n};\n"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "module.meta.json",
|
|
63
|
+
"content": "{\n \"id\": \"workshop\",\n \"type\": \"feature\",\n \"summary\": \"Workshop: players and the publisher share content (maps, levels, skins, models) — free, for in-game resources, or unlocked by holding items.\",\n \"description\": \"A ready catalog of user-generated content for ANY game. Players publish what they made (a world, a level, a skin, any file the title allows), set how others get it — free, a price in in-game resources (several options at once, the author receives it minus commission), or 'hold N of this item / currency to unlock' (nothing is charged; while held or once) — and others browse, filter, acquire, open, like, favorite, follow authors and report. The publisher configures content types, formats, limits, commission and moderation in the Workshop section of the title config, curates featured collections and publishes official content from the dashboard. A game plugs its own content in by registering a handler with ctx.content.registerType({ type, label, listLocal, capture, open, modeId }) — the Workshop never imports the game. VoxelCraft registers 'voxelcraft.world'.\",\n \"provides\": [\n \"player-made content catalog (maps, levels, skins, models, any file type the title allows)\",\n \"publish a map or level made in the game\",\n \"sell content for in-game resources with author royalties minus commission\",\n \"free content sharing\",\n \"unlock content by holding an item or currency without spending it\",\n \"likes, favorites and following content creators\",\n \"report content and auto-hide after reports\",\n \"publisher featured collections and official content\",\n \"download and open shared content in the game\"\n ],\n \"tags\": [\n \"workshop\",\n \"ugc\",\n \"user-generated-content\",\n \"maps\",\n \"levels\",\n \"mods\",\n \"sharing\",\n \"marketplace\",\n \"creators\",\n \"social\"\n ],\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\"\n}\n"
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# AI generation data model — reference\n\nFull shapes of the requests, responses and the feature view, the hard caps a\nfeature cannot exceed, the order in which the server checks a request, and how\ncharges, refunds and async jobs behave. Everything is **strictly typed** and\nexported from `@idosgames/core` (`AIRequest`, `AITextInput`, …,\n`AIGenerationView`, `AIPublicDefinitions`, `AIPublicFeature`). Response schemas\nare not `.strict()` — fields the backend adds later still parse. Field names\nare PascalCase (straight from the backend JSON). Source of truth on the\nserver: `IDosGamesSDK/API/Client/v2/AI/Models/AIRequest.cs` and\n`AIDefinitions.cs` in `iDos_Games_Engine`.\n\n## Contents\n\n- [Route](#route)\n- [Request](#request)\n- [Responses](#responses)\n- [AIPublicFeature — what the client may send](#aipublicfeature--what-the-client-may-send)\n- [Hard caps](#hard-caps)\n- [Server order of checks](#server-order-of-checks)\n- [Charges, refunds, idempotency](#charges-refunds-idempotency)\n- [Jobs](#jobs)\n- [What the publisher configures](#what-the-publisher-configures)\n\n---\n\n## Route\n\n`POST {baseUrl}/api/v2/{titleID}/Client/AI/{Action}/{userID}` with\n`Authorization: Bearer {ClientSessionTicket}`; envelope\n`{ Success, Error?, Data }`. Actions (`AIAction`): `GetDefinitions`,\n`GenerateText`, `GetText`, `GenerateImage`, `EditImage`, `GetImage`,\n`GenerateVideo`, `GetVideo`, `GenerateAudio`, `GetAudio`, `GenerateMusic`,\n`GetMusic`, `GenerateThreeD`, `GetThreeD`, `ListGenerations`, `GetGeneration`.\n\nSDK transport settings: generate actions — timeout 200 s (they only submit a\njob; a video / 3D submit reaches the provider), **no** automatic transport\nretry; reads — the default 12 s with retries. Poll/read calls of one\ngeneration are throttled per generation, generate calls per action.\n\n## Request\n\n```ts\ninterface AIRequest extends BaseRequest {\n FeatureKey?: string; // required by every generate action\n SelectedOptionID?: string; // PriceOption.OptionID; absent = first available\n Text?: AITextInput; // GenerateText\n Image?: AIImageInput; // GenerateImage\n ImageEdit?: AIImageEditInput; // EditImage\n Video?: AIVideoInput; // GenerateVideo\n Audio?: AIAudioInput; // GenerateAudio\n Music?: AIMusicInput; // GenerateMusic\n ThreeD?: AIThreeDInput; // GenerateThreeD\n GenerationID?: string; // GetText / GetImage / GetAudio / GetMusic / GetVideo / GetThreeD / GetGeneration\n Query?: AIHistoryQuery; // ListGenerations\n // BaseRequest: UserID, ClientSessionTicket, BuildKey, RelatedEntityID (idempotency key), …\n}\n\ninterface AIInputImage {\n // exactly ONE source\n Base64?: string; // raw base64 or a data-URL\n ContentType?: string; // MIME of Base64\n AssetUrl?: string; // Url of an asset THIS title generated\n}\n\ninterface AITextInput {\n Prompt?: string;\n Messages?: { Role: \"user\" | \"assistant\"; Content: string }[]; // history\n Images?: AIInputImage[]; // only with AllowImages; attached to the last message\n MaxTokens?: number; // can only LOWER the feature's MaxTokens\n}\n\ninterface AIImageInput {\n Prompt?: string;\n Size?: string; // \"1024x1024\"\n Quality?: string; // \"low\" | \"medium\" | \"high\"\n Format?: string; // \"png\" | \"jpeg\" | \"webp\"\n Background?: string; // \"transparent\" | \"opaque\" | \"auto\"\n N?: number; // number of images, capped by MaxImages; absent/0 = 1\n}\n\ninterface AIImageEditInput {\n Prompt?: string;\n Images?: AIInputImage[];\n Size?: string;\n Format?: string;\n}\ninterface AIVideoInput {\n Prompt?: string;\n Size?: string;\n Seconds?: number;\n ReferenceImage?: AIInputImage;\n}\ninterface AIAudioInput {\n Text?: string;\n Voice?: string;\n Format?: string;\n} // \"mp3\" | \"wav\" | \"opus\"\ninterface AIMusicInput {\n Prompt?: string;\n Format?: string;\n} // \"mp3\" | \"wav\"\ninterface AIThreeDInput {\n Prompt?: string;\n Image?: AIInputImage;\n ArtStyle?: string;\n Format?: string;\n}\n// ArtStyle \"realistic\" | \"sculpture\"; Format \"glb\" | \"fbx\" | \"obj\" | \"usdz\"\n\ninterface AIHistoryQuery {\n Modality?: AIModality;\n FeatureKey?: string;\n Status?: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Skip?: number;\n Limit?: number; // 1..50, server default 20\n}\n```\n\nThere is intentionally **no** `Model`, prompt template or price field.\n\n## Responses\n\n```ts\ninterface AIGenerationView {\n GenerationID: string;\n FeatureKey?: string;\n Modality?: \"Text\" | \"Image\" | \"Video\" | \"Audio\" | \"Music\" | \"ThreeD\";\n Status: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Progress?: number; // 0..100, async jobs, when the provider reports it\n OutputText?: string; // Text\n Assets?: { Url?: string; ContentType?: string; Variant?: string }[];\n Error?: { Code?: string; Message?: string }; // failed generations; safe text, never vendor text\n Charge?: ResourceOperation; // in-game resources taken from the player\n PlayerRefunded?: boolean; // true = failed after charging, Charge was returned\n CreatedAt?: string; // ISO\n UpdatedAt?: string;\n}\n\ninterface AIGenerationListView {\n Data?: AIGenerationView[];\n Skip?: number;\n Limit?: number;\n}\n\ninterface AIPublicDefinitions {\n Enabled?: boolean; // master switch; false = every generate fails\n Features?: Record<string, AIPublicFeature>; // key = FeatureKey; disabled features are omitted\n}\n```\n\nNo model, usage or publisher price is ever returned.\n\n## AIPublicFeature — what the client may send\n\n| Field | Modality | Meaning |\n| ------------------------------------------------------------------------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------ |\n| `Modality` | all | which generate action the feature accepts |\n| `Async` | all | always `true` — every generation is a job; kept for old clients |\n| `Limits` | all | per-player anti-farm limits (`DailyCap`, `TotalCap`, `CooldownSeconds`, …; 0 = none) |\n| `PriceOptions` | all | the player's price per generation; empty = free; resources and/or ONE store purchase |\n| `Tags` | all | UI grouping only |\n| `HistoryMessages` | Text | how many history messages are kept (0 = one-shot) |\n| `MaxTokens` | Text | response token ceiling (client may only lower it) |\n| `AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes` | Text / edit | input image rules |\n| `MaxImages` | Image | cap of `N` |\n| `AllowEdit` | Image | `editImage` allowed |\n| `AllowReferenceImage` | Video | `ReferenceImage` allowed |\n| `MaxPromptChars` | all | prompt length limit incl. history (0 = none) |\n| `LockBehavior` | all | client overrides (size, voice, count…) are ignored |\n| `ImageSize`, `VideoSize`, `VideoSeconds`, `Voice`, `AudioFormat`, `MusicFormat`, `ThreeDOperation`, `ArtStyle`, `ThreeDFormat` | per modality | the feature's defaults, for UI |\n\n`ThreeDOperation` is `\"text-to-3d\"` (default) or `\"image-to-3d\"` (send\n`ThreeD.Image`).\n\n## Limits and defaults\n\n**There are no platform caps** (owner decision, 13.09.2026): a publisher may set\nany value the chosen **model** supports. The only ceiling is the model itself —\ne.g. `MaxTokens` above the registry model's `MaxOutputTokens` is refused when the\nconfig is saved. What an empty field means:\n\n| What | Default when the feature leaves it empty |\n| ------------------------ | ----------------------------------------------- |\n| Text response tokens | 1024 (any value up to the model's output limit) |\n| Input images per request | 1 |\n| One input image | 4 MB (4194304 bytes) |\n| Input image types | png, jpeg, webp — checked by file signature |\n| Images per image request | 1 |\n\nRead the effective values from `AIPublicFeature` (`getDefinitions()`), never\nhard-code them in the game. A text generation has no time ceiling on the\nserver — it may run for hours while the model keeps writing (only 3 minutes of\nsilence counts as a broken stream), so long answers are fine; keep\n`waitForGeneration`'s `timeoutMs` in line with how long the player will wait. The publisher's credits are reserved for the upper bound (e.g.\n`MaxTokens`) before the call and the difference is returned after it — the\nplayer is not affected by that.\n\n## Server order of checks\n\nFeature (`cfg.AI`) → idempotency → the feature's registry model (enabled,\nmodality, image input, key, price) → gate, schedule, limits, prompt checks,\ninput images → cost estimate → the publisher's plan → the title's caps →\npublisher credit hold → player resources + counters (one atomic operation) →\nthe job is queued (video / 3D: submitted to the provider) → the generate call\nanswers `pending` / `running`. Then the server's worker: provider → assets\nstored → money finalized. A refusal before the player resources step charges\nnothing and comes back as `Success: false` with `Error = \"AI_CODE: message\"`.\nAnything failing **after** a charge returns everything the player paid.\n\nPublisher-side problems (no credits, plan without InApp AI, model without a\nprice, cap reached) are deliberately reported to the player as\n`AI_UNAVAILABLE` only.\n\n## Charges, refunds, idempotency\n\n- `Charge` is the `ResourceOperation` taken from the player (its `Consume`).\n The SDK applies it to the cache **once**, from the generate response, and\n never from polls or history (they carry the same `Charge`).\n- `PlayerRefunded: true` — the generation failed after charging and the\n server returned the player's resources. The view still shows the original\n `Charge`; the SDK never applies it, and if it applied it earlier in this\n session (an async job is charged at submit) it re-reads the inventory\n (`client.user.getUserInventory()`) before emitting the events. A `failed`\n generation with `PlayerRefunded: false` keeps its charge.\n- `RelatedEntityID` is the idempotency key, scoped by the server to title and\n player (`iaai_{title}_{user}_{key}`). The server keeps only `[A-Za-z0-9_-]`\n and the first 128 characters, so the SDK sends a plain UUID and rejects a\n caller key outside `^[A-Za-z0-9_-]{1,128}$` (`reason: \"client\"`) —\n otherwise two different keys could silently become one generation. A repeat\n returns the same generation and charges nothing.\n\n## Jobs\n\nEvery generate call returns a job: `pending` (text, image, edit, speech, music —\nqueued on the server and run by its worker within seconds) or `running`\n(video, 3D — submitted to the provider). Wait with `waitForGeneration`, or\npoll the modality's own action (`getText` / `getImage` / `getAudio` /\n`getMusic` / `getVideo` / `getThreeD`). The publisher's credits are reserved\nat submit; a video / 3D job is finalized lazily — on the player's poll, on a\nhistory read, or at the start of another generation of the title. A job nobody\npolls still resolves, but the client only learns the outcome by polling or\nreading history.\n\nThe server never repeats a provider call after a timeout (the provider would\nbill it twice). A generation that fails returns everything to the player; the\npublisher pays what the provider actually charged for it.\n\n## What the publisher configures\n\n`cfg.AI` is a **server-only** section of the title config (it holds prompts\nand models): it never reaches the client config or the CDN. Per feature the\npublisher sets `Enabled`, `Modality`, `Model` (a logical id from the\nplatform's model registry), `Behavior` (system instructions and the\nmodality defaults above), `Safety` (`MaxPromptChars`, `BlockedTerms`,\n`LockBehavior`), `Gate` (audience), `Schedule`, `Limits`, `PriceOptions`,\n`Tags` — in the dashboard or via the backend title-config MCP (`save_ai`).\nWhen a game needs a feature that doesn't exist, tell the publisher what to\nconfigure; don't work around it on the client.\n"
|
|
8
|
+
"content": "# AI generation data model — reference\n\nFull shapes of the requests, responses and the feature view, the hard caps a\nfeature cannot exceed, the order in which the server checks a request, and how\ncharges, refunds and async jobs behave. Everything is **strictly typed** and\nexported from `@idosgames/core` (`AIRequest`, `AITextInput`, …,\n`AIGenerationView`, `AIPublicDefinitions`, `AIPublicFeature`). Response schemas\nare not `.strict()` — fields the backend adds later still parse. Field names\nare PascalCase (straight from the backend JSON). Source of truth on the\nserver: `IDosGamesSDK/API/Client/v2/AI/Models/AIRequest.cs` and\n`AIDefinitions.cs` in `iDos_Games_Engine`.\n\n## Contents\n\n- [Route](#route)\n- [Request](#request)\n- [Responses](#responses)\n- [AIPublicFeature — what the client may send](#aipublicfeature--what-the-client-may-send)\n- [Hard caps](#hard-caps)\n- [Server order of checks](#server-order-of-checks)\n- [Charges, refunds, idempotency](#charges-refunds-idempotency)\n- [Jobs](#jobs)\n- [What the publisher configures](#what-the-publisher-configures)\n\n---\n\n## Route\n\n`POST {baseUrl}/api/v2/{titleID}/Client/AI/{Action}/{userID}` with\n`Authorization: Bearer {ClientSessionTicket}`; envelope\n`{ Success, Error?, Data }`. Actions (`AIAction`): `GetDefinitions`,\n`GenerateText`, `GetText`, `GenerateImage`, `EditImage`, `GetImage`,\n`GenerateVideo`, `GetVideo`, `GenerateAudio`, `GetAudio`, `GenerateMusic`,\n`GetMusic`, `GenerateThreeD`, `GetThreeD`, `ListGenerations`, `GetGeneration`.\n\nSDK transport settings: generate actions — timeout 200 s (they only submit a\njob; a video / 3D submit reaches the provider), **no** automatic transport\nretry; reads — the default 12 s with retries. Poll/read calls of one\ngeneration are throttled per generation, generate calls per action.\n\n## Request\n\n```ts\ninterface AIRequest extends BaseRequest {\n FeatureKey?: string; // required by every generate action\n SelectedOptionID?: string; // PriceOption.OptionID; absent = first available\n Text?: AITextInput; // GenerateText\n Image?: AIImageInput; // GenerateImage\n ImageEdit?: AIImageEditInput; // EditImage\n Video?: AIVideoInput; // GenerateVideo\n Audio?: AIAudioInput; // GenerateAudio\n Music?: AIMusicInput; // GenerateMusic\n ThreeD?: AIThreeDInput; // GenerateThreeD\n GenerationID?: string; // GetText / GetImage / GetAudio / GetMusic / GetVideo / GetThreeD / GetGeneration\n Query?: AIHistoryQuery; // ListGenerations\n // BaseRequest: UserID, ClientSessionTicket, BuildKey, RelatedEntityID (idempotency key), …\n}\n\ninterface AIInputImage {\n // exactly ONE source\n Base64?: string; // raw base64 or a data-URL\n ContentType?: string; // MIME of Base64\n AssetUrl?: string; // Url of an asset THIS title generated\n}\n\ninterface AITextInput {\n Prompt?: string;\n Messages?: { Role: \"user\" | \"assistant\"; Content: string }[]; // history\n Images?: AIInputImage[]; // only with AllowImages; attached to the last message\n MaxTokens?: number; // can only LOWER the feature's MaxTokens\n}\n\ninterface AIImageInput {\n Prompt?: string;\n Size?: string; // \"1024x1024\"\n Quality?: string; // \"low\" | \"medium\" | \"high\"\n Format?: string; // \"png\" | \"jpeg\" | \"webp\"\n Background?: string; // \"transparent\" | \"opaque\" | \"auto\"\n N?: number; // number of images, capped by MaxImages; absent/0 = 1\n}\n\ninterface AIImageEditInput {\n Prompt?: string;\n Images?: AIInputImage[];\n Size?: string;\n Format?: string;\n}\ninterface AIVideoInput {\n Prompt?: string;\n Size?: string;\n Seconds?: number;\n ReferenceImage?: AIInputImage;\n}\ninterface AIAudioInput {\n Text?: string;\n Voice?: string;\n Format?: string;\n} // \"mp3\" | \"wav\" | \"opus\"\ninterface AIMusicInput {\n Prompt?: string;\n Format?: string;\n} // \"mp3\" | \"wav\"\ninterface AIThreeDInput {\n Prompt?: string;\n Image?: AIInputImage;\n ArtStyle?: string;\n Format?: string;\n}\n// ArtStyle \"realistic\" | \"sculpture\"; Format \"glb\" | \"fbx\" | \"obj\" | \"usdz\"\n\ninterface AIHistoryQuery {\n Modality?: AIModality;\n FeatureKey?: string;\n Status?: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Skip?: number;\n Limit?: number; // 1..50, server default 20\n}\n```\n\nThere is intentionally **no** `Model`, prompt template or price field.\n\n## Responses\n\n```ts\ninterface AIGenerationView {\n GenerationID: string;\n FeatureKey?: string;\n Modality?: \"Text\" | \"Image\" | \"Video\" | \"Audio\" | \"Music\" | \"ThreeD\";\n Status: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Progress?: number; // 0..100, async jobs, when the provider reports it\n OutputText?: string; // Text\n Assets?: { Url?: string; ContentType?: string; Variant?: string }[];\n Error?: { Code?: string; Message?: string }; // failed generations; safe text, never vendor text\n Charge?: ResourceOperation; // in-game resources taken from the player\n PlayerRefunded?: boolean; // true = failed after charging, Charge was returned\n CreatedAt?: string; // ISO\n UpdatedAt?: string;\n}\n\ninterface AIGenerationListView {\n Data?: AIGenerationView[];\n Skip?: number;\n Limit?: number;\n}\n\ninterface AIPublicDefinitions {\n Enabled?: boolean; // master switch; false = every generate fails\n Features?: Record<string, AIPublicFeature>; // key = FeatureKey; disabled features are omitted\n}\n```\n\nNo model, usage or publisher price is ever returned.\n\n## AIPublicFeature — what the client may send\n\n| Field | Modality | Meaning |\n| ------------------------------------------------------------------------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------ |\n| `Modality` | all | which generate action the feature accepts |\n| `Async` | all | always `true` — every generation is a job; kept for old clients |\n| `Limits` | all | per-player anti-farm limits (`DailyCap`, `TotalCap`, `CooldownSeconds`, …; 0 = none) |\n| `PriceOptions` | all | the player's price per generation; empty = free; resources and/or ONE store purchase |\n| `Tags` | all | UI grouping only |\n| `HistoryMessages` | Text | how many history messages are kept (0 = one-shot) |\n| `MaxTokens` | Text | response token ceiling (client may only lower it) |\n| `AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes` | Text / edit | input image rules |\n| `MaxImages` | Image | cap of `N` |\n| `AllowEdit` | Image | `editImage` allowed |\n| `AllowReferenceImage` | Video | `ReferenceImage` allowed |\n| `MaxPromptChars` | all | prompt length limit incl. history (0 = none) |\n| `LockBehavior` | all | client overrides (size, voice, count…) are ignored |\n| `ImageSize`, `VideoSize`, `VideoSeconds`, `Voice`, `AudioFormat`, `MusicFormat`, `ThreeDOperation`, `ArtStyle`, `ThreeDFormat` | per modality | the feature's defaults, for UI |\n\n`ThreeDOperation` is `\"text-to-3d\"` (default) or `\"image-to-3d\"` (send\n`ThreeD.Image`).\n\n## Limits and defaults\n\n**There are no platform caps** (owner decision, 13.09.2026): a publisher may set\nany value the model behind the chosen **mode** supports. The only ceiling is that model —\ne.g. `MaxTokens` above the mode's model `MaxOutputTokens` is refused when the\nconfig is saved. What an empty field means:\n\n| What | Default when the feature leaves it empty |\n| ------------------------ | ----------------------------------------------- |\n| Text response tokens | 1024 (any value up to the model's output limit) |\n| Input images per request | 1 |\n| One input image | 4 MB (4194304 bytes) |\n| Input image types | png, jpeg, webp — checked by file signature |\n| Images per image request | 1 |\n\nRead the effective values from `AIPublicFeature` (`getDefinitions()`), never\nhard-code them in the game. A text generation has no time ceiling on the\nserver — it may run for hours while the model keeps writing (only 3 minutes of\nsilence counts as a broken stream), so long answers are fine; keep\n`waitForGeneration`'s `timeoutMs` in line with how long the player will wait. The publisher's credits are reserved for the upper bound (e.g.\n`MaxTokens`) before the call and the difference is returned after it — the\nplayer is not affected by that.\n\n## Server order of checks\n\nFeature (`cfg.AI`) → idempotency → the feature's mode / tier resolved to its registry model (enabled,\nmodality, image input, key, price) → gate, schedule, limits, prompt checks,\ninput images → cost estimate → the publisher's plan → the title's caps →\npublisher credit hold → player resources + counters (one atomic operation) →\nthe job is queued (video / 3D: submitted to the provider) → the generate call\nanswers `pending` / `running`. Then the server's worker: provider → assets\nstored → money finalized. A refusal before the player resources step charges\nnothing and comes back as `Success: false` with `Error = \"AI_CODE: message\"`.\nAnything failing **after** a charge returns everything the player paid.\n\nPublisher-side problems (no credits, plan without InApp AI, a mode / tier the\nplan does not include, model without a price, cap reached) are deliberately reported to the player as\n`AI_UNAVAILABLE` only.\n\n## Charges, refunds, idempotency\n\n- `Charge` is the `ResourceOperation` taken from the player (its `Consume`).\n The SDK applies it to the cache **once**, from the generate response, and\n never from polls or history (they carry the same `Charge`).\n- `PlayerRefunded: true` — the generation failed after charging and the\n server returned the player's resources. The view still shows the original\n `Charge`; the SDK never applies it, and if it applied it earlier in this\n session (an async job is charged at submit) it re-reads the inventory\n (`client.user.getUserInventory()`) before emitting the events. A `failed`\n generation with `PlayerRefunded: false` keeps its charge.\n- `RelatedEntityID` is the idempotency key, scoped by the server to title and\n player (`iaai_{title}_{user}_{key}`). The server keeps only `[A-Za-z0-9_-]`\n and the first 128 characters, so the SDK sends a plain UUID and rejects a\n caller key outside `^[A-Za-z0-9_-]{1,128}$` (`reason: \"client\"`) —\n otherwise two different keys could silently become one generation. A repeat\n returns the same generation and charges nothing.\n\n## Jobs\n\nEvery generate call returns a job: `pending` (text, image, edit, speech, music —\nqueued on the server and run by its worker within seconds) or `running`\n(video, 3D — submitted to the provider). Wait with `waitForGeneration`, or\npoll the modality's own action (`getText` / `getImage` / `getAudio` /\n`getMusic` / `getVideo` / `getThreeD`). The publisher's credits are reserved\nat submit; a video / 3D job is finalized lazily — on the player's poll, on a\nhistory read, or at the start of another generation of the title. A job nobody\npolls still resolves, but the client only learns the outcome by polling or\nreading history.\n\nThe server never repeats a provider call after a timeout (the provider would\nbill it twice). A generation that fails returns everything to the player; the\npublisher pays what the provider actually charged for it.\n\n## What the publisher configures\n\n`cfg.AI` is a **server-only** section of the title config (it holds prompts\nand models): it never reaches the client config or the CDN. Per feature the\npublisher sets `Enabled`, `Modality`, `Model` (the id of an AI **mode** for Text,\ne.g. `standard`, or of a generation **tier** for media, e.g. `image-balanced` —\nthe same list the publisher sees in the AI Coder and in asset generation, from\nthe dashboard action `GetInAppAIModels`; never a model name — the server refuses\nit), `Behavior` (system instructions and the\nmodality defaults above), `Safety` (`MaxPromptChars`, `BlockedTerms`,\n`LockBehavior`), `Gate` (audience), `Schedule`, `Limits`, `PriceOptions`,\n`Tags` — in the dashboard or via the backend title-config MCP (`save_ai`).\nWhen a game needs a feature that doesn't exist, tell the publisher what to\nconfigure; don't work around it on the client.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "analytics-events",
|
|
3
|
+
"description": "Log custom analytics events from a game on the iDosGames TypeScript SDK (@idosgames/core) via client.analytics (AnalyticsService) — the Firebase Analytics equivalent of the platform: logEvent(name, params, value), logScreenView, flush, player consent (setEnabled), automatic events (idos_first_open, idos_session_start, idos_app_update, idos_os_update), and the title's Analytics config section (Enabled, EventNaming Open/Declared, declared and blocked events, custom dimensions, retention days). Use this whenever the user wants to track what players do, funnels of their own events, \"how many players reached level 10\", revenue or score per event, breakdowns by platform / app version / OS / device / language / country, goals of an A/B experiment, or touches client.analytics, logEvent, AnalyticsDefinitions, LogEventsResponse or the analytics:* events — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: analytics-events\ndescription: >-\n Log custom analytics events from a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.analytics (AnalyticsService) — the Firebase\n Analytics equivalent of the platform: logEvent(name, params, value),\n logScreenView, flush, player consent (setEnabled), automatic events\n (idos_first_open, idos_session_start, idos_app_update, idos_os_update), and\n the title's Analytics config section (Enabled, EventNaming Open/Declared,\n declared and blocked events, custom dimensions, retention days). Use this\n whenever the user wants to track what players do, funnels of their own\n events, \"how many players reached level 10\", revenue or score per event,\n breakdowns by platform / app version / OS / device / language / country,\n goals of an A/B experiment, or touches client.analytics, logEvent,\n AnalyticsDefinitions, LogEventsResponse or the analytics:* events — even if\n they don't name the module explicitly.\n---\n\n# Custom events (iDosGames TS SDK)\n\n`client.analytics` is the platform's Firebase Analytics: the game logs its own\nevents, the publisher sees counts, unique players, sums of `value` and\nbreakdowns in the dashboard (**Analytics → Events**), and A/B experiments are\njudged by those events.\n\n```ts\nclient.analytics.logEvent(\"level_complete\", { level: 5, mode: \"hard\" }, 120);\nclient.analytics.logScreenView(\"shop\");\n```\n\n## What you must know first\n\n### 1. The title must switch it on\n\nEvent analytics is **off by default**. The publisher enables it in the title's\n`Analytics` section (dashboard LiveOps → Analytics, or the config agent). While\nit is off the SDK sends **nothing** — `logEvent` returns `ok` and records\nnothing. Don't \"fix\" a silent dashboard by changing game code; check\n`Analytics.Enabled` first.\n\n### 2. Nothing is sent per call — and every batch is billed\n\nEvents are queued and delivered in batches: 25 events, every 30 seconds, and\nwhen the tab is hidden. Each batch is **one API call billed to the publisher**.\nNever call `flush()` after each event; call it only before a deliberate exit\n(a \"Quit\" button). The queue survives a reload, and a batch interrupted mid-\nflight is resent with the same `BatchID` — the server counts it once.\n\n### 3. Names and parameters follow the server's rules\n\n| Rule | Limit |\n| ------------------------ | -------------------------------------- |\n| Event and parameter name | `^[A-Za-z][A-Za-z0-9_]{0,39}$` |\n| Reserved prefixes | `idos_`, `firebase_`, `google_`, `ga_` |\n| Parameters per event | 25; strings cut to 100 chars |\n| `value` | a finite number — summed by the server |\n| Event age | older than 72 h is dropped |\n| Per player per day | 1000 events |\n\nInvalid input fails **locally** (`result.ok === false`, reason `client`) so it\nnever costs a call.\n\n### 4. Only registered parameters are kept\n\nThe server stores a parameter's values only if the publisher registered it as\na **custom dimension** (`Analytics.Dimensions[].ParamName`, limit set by the\nplan). Other parameters are accepted and ignored. If the user wants a breakdown\n\"by level\", the `level` parameter must be registered — tell them.\n\n### 5. Naming modes\n\n- `Open` (default): any valid name is accepted and collected into the title's\n catalog, up to the plan's name limit; beyond it events come back dropped with\n `CatalogFull`.\n- `Declared`: only names listed in `Analytics.Events` are accepted — the SDK\n refuses the rest locally. A declared event with `Blocked: true` is refused in\n both modes.\n\n## Automatic events\n\nLogged by the SDK once the config says analytics is on (disable with\n`Analytics.AutomaticEvents: false`):\n\n| Event | When |\n| -------------------- | ------------------------------------------------------------- |\n| `idos_first_open` | first launch on the device |\n| `idos_session_start` | launch or return after 30 minutes away |\n| `idos_app_update` | `settings.appVersion` changed (`previous_app_version`) |\n| `idos_os_update` | OS version changed (`previous_os_version`) |\n| `idos_screen_view` | `logScreenView(name, class?)` (`screen_name`, `screen_class`) |\n\nPass `appVersion` to `createIDosGamesClient` — without it the \"app version\"\nslice of every report is empty.\n\n## Breakdowns are the server's job\n\nPlatform comes from the `X-IG-Platform` header, country from the IP,\nnew/returning and the acquisition source from the player record — **not** from\nthe client. The SDK adds only app version, OS, device class/model and language.\nDon't put `platform` or `country` into event params to \"help\" — they would be\njust another unregistered parameter.\n\n## Consent\n\n```ts\nclient.analytics.setEnabled(false); // forgets the queue, stops collecting (persisted)\nclient.analytics.setEnabled(true);\n```\n\n## Diagnostics\n\n```ts\nclient.on(\"analytics:eventsDropped\", (dropped) => console.warn(dropped));\n// Reason: InvalidName, Reserved, NotDeclared, Blocked, CatalogFull, TooOld,\n// AutomaticDisabled, DailyLimit — nothing retries a dropped event.\nclient.on(\"analytics:flushed\", (r) => console.debug(r.Accepted));\n```\n\n## From CloudCode\n\nServer scripts log on behalf of a player with\n`server.LogEvent(name, params?, value?)` — same rules, no batching needed.\n\n## Common mistakes\n\n- Logging with the `idos_` prefix to fake sessions — refused.\n- Expecting a breakdown by a parameter that is not a registered dimension.\n- Calling `flush()` in a loop or per event — multiplies the publisher's bill.\n- Reading event counts from the client — there is no client read API; reports\n live in the dashboard only.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloud-code",
|
|
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",
|
|
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, handlers that run on a schedule (cron-like jobs: daily resets, auction settlement) and incoming webhooks from external services (payment callbacks, site form posts, bots).",
|
|
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 handlers that run on a schedule (cron-like jobs: daily resets, auction\n settlement) and incoming webhooks from external services (payment callbacks,\n site form posts, bots).\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### Schedules and incoming webhooks\n\nHandlers can also run **without a player**. Both are declared in the title\nconfig (`CloudCode.Schedules` / `CloudCode.Webhooks`, dashboard → Tools → Cloud\nfunctions) — not by publishing the script. At most 20 of each.\n\n- **Schedule** — runs a handler every `IntervalMinutes` (5 … 10080) from\n `StartAtUtc` (empty = aligned to 00:00 UTC: 60 runs on the hour, 1440 at\n midnight UTC) with `args = { Trigger: \"Schedule\", ScheduleID, ScheduledAtUtc, Args }`\n (`Args` is the schedule's own JSON). There is no player session: pass user ids\n explicitly to `server.*` calls that take one. One run per slot however many\n servers there are; a slot missed during an outage is **not** replayed — make\n the handler idempotent per `ScheduledAtUtc`.\n- **Incoming webhook** — `POST https://api.idosgames.com/api/v2/{titleID}/Public/CloudCode/Webhook/{id}`\n runs a handler for an external service. The request is verified **before**\n the handler runs with a title secret (kind Secret, from Integrations):\n `HmacSha256` — hex HMAC-SHA256 of the raw body (optionally `sha256=`-prefixed)\n in `X-Signature`; `Token` — the secret itself in `X-Webhook-Token` (the header\n name is configurable). `args = { Trigger: \"Webhook\", WebhookID, Headers, Query, Body }`:\n `Body` is the raw text (parse it yourself), header names are lower-case, and\n the signature, `Authorization` and `Cookie` headers are removed. The return\n value is the HTTP 200 body; a script error answers 500 so the sender retries —\n make the handler idempotent by the event id. Unknown, disabled or badly signed\n requests all get the same 401. Body ≤ 256 KB.\n\n```js\nhandlers.onPayment = function (args, context) {\n var evt = JSON.parse(args.Body);\n if (server.GetDataItem(\"payments\", evt.id).Success) return { ok: true }; // a retried delivery\n var res = server.CreateDataItem(\"payments\", { type: evt.type }, evt.id);\n if (!res.Success) throw new Error(res.Error); // 500 → the provider retries\n return { ok: true };\n};\n```\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
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "data-collections",
|
|
3
|
+
"description": "Store, list, search and sort many records of one shape in a game, app or site on the iDosGames TypeScript SDK (@idosgames/core) via client.dataCollections (DataCollectionsService): guilds, auctions/lots, orders, product catalogs, articles/news, saved levels and builds, feeds, mail, invites, lobbies, match history — anything no ready platform module covers. Use this whenever the user wants their OWN data structure with fields, filters, pagination, sharing between users, roles (admin/editor/moderator), public pages readable without login, images or files attached to records (File fields, uploadFile), or touches client.dataCollections, DataCollectionsService, DataQuerySpec, DataCollectionDefinition or the DataCollections config section — even if they don't name it. NOT client.collection (collectible card sets). For one player's simple values use user-custom-data; for one title-wide value use title-custom-data; for currencies/items/store/quests use their modules.",
|
|
4
|
+
"content": "---\nname: data-collections\ndescription: >-\n Store, list, search and sort many records of one shape in a game, app or site\n on the iDosGames TypeScript SDK (@idosgames/core) via client.dataCollections\n (DataCollectionsService): guilds, auctions/lots, orders, product catalogs,\n articles/news, saved levels and builds, feeds, mail, invites, lobbies, match\n history — anything no ready platform module covers. Use this whenever the user\n wants their OWN data structure with fields, filters, pagination, sharing\n between users, roles (admin/editor/moderator), public pages readable without\n login, images or files attached to records (File fields, uploadFile),\n or touches client.dataCollections, DataCollectionsService,\n DataQuerySpec, DataCollectionDefinition or the DataCollections config section —\n even if they don't name it. NOT client.collection (collectible card sets). For\n one player's simple values use user-custom-data; for one title-wide value use\n title-custom-data; for currencies/items/store/quests use their modules.\n---\n\n# Data collections (iDosGames TS SDK)\n\n`client.dataCollections` is the title's own database: the creator declares\n**collections** (typed fields, which fields are searchable, sort keys, access\nrules, limits) in the title's `DataCollections` config section, and the platform\nstores, validates, secures, indexes, counts and bills them.\n\n## Choose storage in this order\n\n1. **A ready module** when it owns the data — currencies, inventory, store,\n lootboxes, quests, leaderboards, chat, social, workshop, match, season.\n2. **user-custom-data** — one player's simple values (settings, flags, one save).\n3. **title-custom-data** — one title-wide value (event phase, global counter).\n4. **Data collections** — MANY records of one shape that are listed, searched,\n sorted or shared between users.\n\n⚠ **Never keep currencies or items inside a record.** Reference them with a\n`Ref` field (`Kind: \"Item\" | \"Currency\" | \"User\" | \"DataItem\" …`) and let the\ncollection's `Economy` charge/grant (`Cost`/`Grant` in engine resource format) —\nit runs in the SAME transaction as the write.\n\n## Declaring a collection (title config → `DataCollections`)\n\n```json\n{\n \"Enabled\": true,\n \"Roles\": { \"moderator\": { \"DisplayName\": \"Moderator\" } },\n \"Collections\": {\n \"guilds\": {\n \"Kind\": \"Shared\",\n \"Mode\": \"Strict\",\n \"Fields\": {\n \"name\": {\n \"Type\": \"String\",\n \"Required\": true,\n \"Unique\": true,\n \"UniqueIgnoreCase\": true,\n \"Indexed\": true,\n \"MaxLength\": 24\n },\n \"region\": {\n \"Type\": \"String\",\n \"Enum\": [\"EU\", \"US\", \"ASIA\"],\n \"Indexed\": true,\n \"Default\": \"\\\"EU\\\"\"\n },\n \"level\": {\n \"Type\": \"Int\",\n \"Default\": \"1\",\n \"Min\": 1,\n \"ClientWritable\": false,\n \"Indexed\": true\n },\n \"members\": {\n \"Type\": \"Array\",\n \"ItemType\": \"Ref\",\n \"Ref\": { \"Kind\": \"User\" },\n \"MaxItems\": 50\n },\n \"motto\": { \"Type\": \"Text\", \"Indexed\": true }\n },\n \"SortKeys\": [\n {\n \"ID\": \"byLevel\",\n \"Components\": [{ \"Field\": \"level\", \"Descending\": true }]\n },\n {\n \"ID\": \"byRegionLevel\",\n \"Components\": [\n { \"Field\": \"region\" },\n { \"Field\": \"level\", \"Descending\": true }\n ]\n }\n ],\n \"Access\": {\n \"Read\": [{ \"Mode\": \"Authenticated\" }],\n \"Create\": [{ \"Mode\": \"Authenticated\" }],\n \"Update\": [\n { \"Mode\": \"Owner\" },\n { \"Mode\": \"Members\", \"MembersField\": \"members\" },\n { \"Mode\": \"Role\", \"Role\": \"moderator\" }\n ],\n \"Delete\": [{ \"Mode\": \"Owner\" }]\n },\n \"Counters\": [{ \"ID\": \"byRegion\", \"Kind\": \"Count\", \"GroupBy\": \"region\" }],\n \"Economy\": {\n \"OnCreate\": {\n \"Cost\": {\n \"Entries\": [\n { \"Type\": \"VirtualCurrency\", \"CurrencyID\": \"GOLD\", \"Amount\": 500 }\n ]\n }\n }\n }\n }\n }\n}\n```\n\n| `Kind` | For | Notes |\n| ----------- | -------------------------------------------------------- | ---------------------------------------------- |\n| `UserOwned` | each record belongs to one user (saves, orders) | owner = creator |\n| `Shared` | records many users use (guilds, lots, catalog, articles) | client creator becomes owner |\n| `Temporary` | short-lived (invites, lobbies, carts) | mandatory TTL (`Limits.TtlSeconds`, ≤ 30 days) |\n| `Log` | append-only, read by time (history, feed, messages) | no updates |\n\nAccess modes (a list per operation, OR-ed): `Anyone` (read only — sites without\nlogin), `Authenticated`, `Owner`, `Members` (an Array of Ref(User) field),\n`Role` (from `Roles`; assigned only by CloudCode `server.SetUserRoles` or the\ndashboard), `Server` (CloudCode/dashboard only). No list = server only.\n\nField visibility: `Everyone` / `Owner` / `Server`. `ClientWritable: false` =\nonly the server writes it (scores, statuses, levels). `Immutable` = fixed after\ncreate.\n\n## Queries: only by what you declared\n\n- Filter ONLY by `Indexed` fields: `eq`, `in`, `gt/gte/lt/lte`, `contains`\n (array element), `prefix` (String, case-insensitive). `Text` fields are\n searched by words and word prefixes (`Text: \"drag\"` finds \"Dragon\").\n- Order ONLY by a declared `SortKey`. Equality filters on its leading fields pick\n the range; a range filter on the next field narrows it.\n- Anything else is refused with `INVALID_QUERY` — declare the field/sort key in\n the same change as the code that queries it.\n\n## Client\n\n```ts\nimport { createIDosGamesClient, where } from \"@idosgames/core\";\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID();\nconst data = client.dataCollections;\n\n// create (pay 500 gold in the same transaction); own id makes retries safe\nconst g = await data.create(\n \"guilds\",\n { name: \"Wolves\", region: \"EU\" },\n { itemID: crypto.randomUUID() },\n);\nif (!g.ok) return showError(g.error); // e.g. \"UNIQUE_VIOLATION: …\", \"INVALID_DATA: …\"\n\n// top EU guilds, 20 per page\nconst page = await data.query(\"guilds\", {\n Where: [where(\"region\", \"eq\", \"EU\")],\n OrderBy: \"byRegionLevel\",\n Limit: 20,\n});\nconst next = page.ok && page.data.NextCursor; // pass back as Cursor\n\nawait data.update(\"guilds\", id, { AddToSet: { members: userId } });\nawait data.update(\n \"guilds\",\n id,\n { Set: { motto: \"Hunt!\" } },\n { expectedVersion: 3 },\n); // CAS\nconst mine = await data.query(\"guilds\", { MemberOfMine: true });\nconst eu = await data.getCounter(\"guilds\", \"byRegion\", \"EU\");\nconst withRefs = await data.get(\"lots\", lotId, { expand: [\"item\", \"seller\"] });\nawait data.batch([\n {\n Op: \"Create\",\n Collection: \"lots\",\n Data: { item: \"weapons/sword\", price: 100 },\n },\n {\n Op: \"Update\",\n Collection: \"guilds\",\n ItemID: id,\n Update: { Set: { motto: \"Sale!\" } },\n },\n]); // all or nothing\n```\n\nWithout login (collections with `Read: Anyone`):\n\n```ts\nconst latest = await client.dataCollections.readPublicView(\n \"articles\",\n \"latest\",\n); // CDN pages\nconst found = await client.dataCollections.searchPublic(\"articles\", {\n Text: \"patch\",\n}); // needs AnonymousSearch\n```\n\n## Files (File fields)\n\nA `File` field stores a **file id**; the bytes live in a private bucket. Upload\nfirst, then write the id — the server binds the file to the record in the same\nwrite and checks the real bytes against the declared type.\n\n```json\n\"cover\": { \"Type\": \"File\", \"AllowedMimeTypes\": [\"image/png\", \"image/webp\"], \"MaxFileBytes\": 2097152 }\n```\n\n```ts\nconst up = await client.dataCollections.uploadFile(\n \"levels\",\n \"cover\",\n fileFromInput,\n); // Blob / File\nif (!up.ok) return showError(up.error); // \"INVALID_FILE: …\", \"FILE_TOO_LARGE: …\"\nawait client.dataCollections.create(\"levels\", {\n name: \"Canyon\",\n cover: up.data.FileID,\n});\n\nconst lvl = await client.dataCollections.get(\"levels\", id);\nimg.src = lvl.ok ? (lvl.data.Item.Files?.cover?.Url ?? \"\") : \"\"; // signed, expires in ~10 min\n```\n\n- Types: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`,\n `model/gltf+json`, `model/gltf-binary`, `application/zip`, `text/plain`,\n `application/octet-stream`. Default max 10 MB, at most 100 MB per file.\n- The client needs write access to the collection (a Create rule or a non-server\n Update rule) and a client-writable field. Only the uploader can attach their\n upload; CloudCode can attach any.\n- One file — one record: reusing an id is `FILE_IN_USE`. Replacing the id or\n deleting the record deletes the old file. An upload never written into a record\n is removed after a day; the upload link itself lives 30 minutes.\n- Never store `Url` — it is signed and short-lived. Read the record again for a\n fresh link. Files count toward the account's storage like records.\n\n## Server (CloudCode)\n\nScripts are the server: rules do not apply to them, server-only fields are\nwritable, the same validation runs. All return `{ Success, Error, Data }`.\n\n```js\nhandlers.createGuild = function (args, context) {\n var res = server.CreateDataItem(\n \"guilds\",\n { name: args.name },\n null,\n context.userId,\n ); // 4th arg = owner\n if (!res.Success) throw new Error(res.Error);\n return res.Data.Item;\n};\n// server.GetDataItem / GetDataItems / QueryDataItems / CountDataItems / UpdateDataItem /\n// DeleteDataItem / BatchDataItems / GetDataCounter / GetUserRoles / SetUserRoles\n```\n\n`Integrations` on a collection connect it to other modules: `QuestMetricOn*`\n(advances quest objectives with `Source: ServerApi`), `AnalyticsEventOn*`,\n`TriggerOn*` (a CloudCode handler run asynchronously after the write with\n`args = { Trigger, Collection, ItemID, Item, Previous }`).\n\n## Gotchas\n\n- **Shared + server create without an owner = a title record** (nobody owns\n it). Pass `context.userId` as the 4th argument to make it a player's.\n- **`ALREADY_EXISTS` is not an error for a retried create** — the service\n returns the record with `AlreadyExists: true` and nothing is charged twice.\n- **Counters are atomic but not allowed on expiring records** (Temporary, Log,\n TtlSeconds).\n- **Public views are seconds behind** the database; use a logged-in `query` when\n freshness matters.\n- **The plan caps** collections, searchable fields, sort keys, record size and\n storage per title. Records and files count toward the account's hosting\n storage and are billed by the same plan quota as any other file.\n- **Deleting a collection's schema keeps its records** (they still take\n storage). Turn the collection off, save, then dashboard → Data → \"Delete all\n records\" (refused while the collection is on), and only then delete the schema.\n- **A hidden field (Server/Owner) can't be filtered by a client** — neither as\n an Indexed field nor as a sort-key component; ordering by such a key works.\n- **File uploads are capped** at 100 tickets per user per hour.\n- **Module collections are namespaced**: a front-end module declares its own in\n `module.json` → `collections` with ids like `\"guild-system:guilds\"`.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "experiments-system",
|
|
3
|
+
"description": "Configure and consume A/B experiments on the iDosGames platform: the title's Experiment config section (variants with weights and Params, audience Gate, Schedule, Salt, LayerID, sticky assignment, Goals, ActivationEvent, RolloutVariantID), gating content by variant through SegmentGate.Experiment in any module, and reading the player's variant and its remote-config Params in a game on the TypeScript SDK via client.experiments (ExperimentsService: load, getVariant, getParam, isInVariant, getStatus). Use this whenever the user wants an A/B test, a split test, remote config per variant, a price / offer / difficulty experiment, rolling out a winner, or touches ExperimentDefinitions, ExperimentGoal, PlayerExperimentView, GetExperimentsResponse or the experiments:* events — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: experiments-system\ndescription: >-\n Configure and consume A/B experiments on the iDosGames platform: the\n title's Experiment config section (variants with weights and Params,\n audience Gate, Schedule, Salt, LayerID, sticky assignment, Goals,\n ActivationEvent, RolloutVariantID), gating content by variant through\n SegmentGate.Experiment in any module, and reading the player's variant and\n its remote-config Params in a game on the TypeScript SDK via\n client.experiments (ExperimentsService: load, getVariant, getParam,\n isInVariant, getStatus). Use this whenever the user wants an A/B test, a\n split test, remote config per variant, a price / offer / difficulty\n experiment, rolling out a winner, or touches ExperimentDefinitions,\n ExperimentGoal, PlayerExperimentView, GetExperimentsResponse or the\n experiments:* events — even if they don't name the module explicitly.\n---\n\n# A/B experiments (iDosGames)\n\nAn experiment is **who** (`Gate`), **when** (`Schedule`) and a random,\nsticky split of players into **variants**. It is judged by **goals**, most of\nthem built on custom events (`analytics-events` skill).\n\n⚠ **A/B testing is a paid-plan feature.** On a plan without it the dashboard\nrefuses to save a running experiment (`AB_TESTING_NOT_IN_PLAN`), the report is\nunavailable, and every experiment of the title behaves as stopped — everyone\ngets control.\n\n## Config (`Experiment.Experiments.<id>`)\n\n```jsonc\n{\n \"ExperimentID\": \"price_test\",\n \"IsEnabled\": true,\n \"Schedule\": {/* optional window */},\n \"Gate\": {/* optional audience, same SegmentGate as everywhere */},\n \"Variants\": [\n {\n \"VariantID\": \"control\",\n \"IsControl\": true,\n \"Weight\": 50,\n \"Params\": { \"discount\": \"0\" },\n },\n { \"VariantID\": \"B\", \"Weight\": 50, \"Params\": { \"discount\": \"15\" } },\n ],\n \"Goals\": [\n {\n \"MetricID\": \"buy\",\n \"Kind\": \"EventConversion\",\n \"EventName\": \"shop_purchase\",\n \"IsPrimary\": true,\n },\n { \"MetricID\": \"d7\", \"Kind\": \"RetentionD7\" },\n ],\n \"ActivationEvent\": \"shop_open\",\n \"RolloutVariantID\": null,\n}\n```\n\n- Exactly one variant should be `IsControl` — it is what everyone gets when the\n experiment is not running.\n- `Params` values are **strings**; the client types them (`getParam` below).\n- Goal kinds: `EventConversion`, `EventCountPerUser`, `EventValuePerUser`\n (need `EventName`), `RetentionD1`, `RetentionD7`, `PayerConversion`, `Arpu`.\n One goal may be `IsPrimary` — it decides the verdict.\n- `ActivationEvent`: a player counts as a participant only after sending this\n event (like Firebase's activation event) — use it when the variant matters\n only on a screen most players never open.\n- **Never change `Salt` of a running experiment** — it reassigns players, and\n the report mixes both assignments.\n\n## What \"not running\" means\n\n| State | Every player gets |\n| ----------------------------------------------------------- | --------------------- |\n| Running | own sticky assignment |\n| `IsEnabled: false`, outside `Schedule`, or plan without A/B | the control variant |\n| `RolloutVariantID` set | that variant |\n\nStored assignments are never deleted — the report's history stays intact.\n\n## Gating content by variant (server-side)\n\nAny module with a `SegmentGate` (store slots, deal offers, quests, rewards, …)\naccepts an experiment condition. The server resolves it — the store simply\nreturns the offer only to variant B. The game does **not** check variants to\nhide content.\n\n## Reading variant params in the game\n\n```ts\nawait client.experiments.load(); // once per session, after login — one billed call\nconst discount = client.experiments.getParam(\"price_test\", \"discount\", 0); // number\nconst showBadge = client.experiments.getParam(\"price_test\", \"badge\", false); // boolean\nif (client.experiments.isInVariant(\"price_test\", \"B\")) {\n /* … */\n}\nclient.experiments.getStatus(\"price_test\"); // \"Running\" | \"Stopped\" | \"RolledOut\" | null\nclient.experiments.isAbTestingAvailable; // false → plan has no A/B\nclient.on(\"experiments:changed\", (changed) => reapply(changed));\n```\n\nBefore `load()` returns, getters answer from the previous session on this\ndevice (same player only) or the default you pass. A missing or unparsable\nvalue always returns the default.\n\n## Reading results\n\nDashboard → Analytics → A/B testing: participants per variant, each goal with\nthe variant's value, the difference, p-value and \"probability to beat\ncontrol\". The verdict needs **both** p < 0.05 **and** the required sample;\n\"Roll out winner\" sets `RolloutVariantID`.\n\nEvents are attributed to the variant the player had **when the event\nhappened** — reassignment later does not move history.\n\n## Common mistakes\n\n- Hiding content client-side by variant instead of gating it on the server.\n- Calling `load()` on every screen — it is billed; once per session is enough.\n- Two variants marked `IsControl`, or none.\n- Changing `Salt` or weights mid-experiment and trusting the old numbers.\n- Goals on events the title does not collect (analytics off, or a name that is\n not declared in `Declared` mode).\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-module-contract",
|
|
3
|
-
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
-
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID). A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`).\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
3
|
+
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud, workshop), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
+
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, game-hud, workshop), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID). A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`).\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|