@idosgames/mcp 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +69 -25
- package/registry/index.json +360 -412
- package/registry/modules/board-game.json +7 -7
- package/registry/modules/{character-ui.json → character.json} +8 -11
- package/registry/modules/chat.json +71 -0
- package/registry/modules/{craft-ui.json → craft.json} +8 -11
- package/registry/modules/{deal-offer-ui.json → deals.json} +7 -10
- package/registry/modules/{events-ui.json → events.json} +8 -11
- package/registry/modules/{friends-ui.json → friends.json} +8 -11
- package/registry/modules/idle-rpg.json +6 -6
- package/registry/modules/{inventory-ui.json → inventory.json} +9 -12
- package/registry/modules/{referral-ui.json → invite.json} +8 -11
- package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -11
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/{profile-ui.json → profile.json} +8 -11
- package/registry/modules/{pvp-ui.json → pvp.json} +9 -12
- package/registry/modules/{rewards-ui.json → rewards.json} +8 -11
- package/registry/modules/{season-ui.json → season.json} +8 -11
- package/registry/modules/{store-ui.json → store.json} +9 -12
- package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -11
- package/registry/modules/voxelcraft.json +2 -446
- package/registry/modules/workshop.json +11 -6
- package/registry/skills/dev-test-loop.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +2 -2
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/chat-ui.json +0 -74
- package/registry/modules/game-hud.json +0 -104
- package/registry/modules/marketplace-ui.json +0 -75
- package/registry/modules/showcase.json +0 -92
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
10
11
|
"summary": "Workshop: players and the publisher share content (maps, levels, skins, models) — free, for in-game resources, or unlocked by holding items.",
|
|
11
12
|
"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
13
|
"provides": [
|
|
@@ -40,27 +41,31 @@
|
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"@idosgames/core": "0.19.0",
|
|
43
|
-
"@idosgames/module-sdk": "0.
|
|
44
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
44
45
|
"@idosgames/react": "0.3.0",
|
|
45
46
|
"react": "19.2.7"
|
|
46
47
|
},
|
|
47
|
-
"contentHash": "
|
|
48
|
+
"contentHash": "8708fd1f761188580603bd1e2cca47209c2d618a9c82a5530cca9c083bca19e6",
|
|
48
49
|
"files": [
|
|
50
|
+
{
|
|
51
|
+
"path": "i18n.ts",
|
|
52
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\n// The Workshop's name in the lobby. The screen itself speaks English for now.\n\nconst EN = {\n title: \"Workshop\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n title: \"Мастерская\",\n});\n"
|
|
53
|
+
},
|
|
49
54
|
{
|
|
50
55
|
"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
|
|
56
|
+
"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 mode of its own) after copying this into src/modules/.\n\nexport { workshopModule } from \"./module\";\nexport {\n makeWorkshopScreen,\n WorkshopApp,\n type WorkshopHost,\n} from \"./WorkshopApp\";\n"
|
|
52
57
|
},
|
|
53
58
|
{
|
|
54
59
|
"path": "module.ts",
|
|
55
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {
|
|
60
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeWorkshopScreen } from \"./WorkshopApp\";\nimport { t } from \"./i18n\";\n\n// The Workshop system: a tab of the lobby (ctx.features) with the catalog of player & publisher\n// content. It works for any game — what a content type IS (how to list, capture and open it) comes\n// from the handlers games register in `ctx.content`; the Workshop never imports a game. With no game\n// that registers a content type there is nothing to share, and the tab stays hidden.\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.features.register({\n id: \"workshop\",\n label: t(\"title\"),\n icon: \"globe\",\n group: \"social\",\n order: 88,\n available: false,\n Screen: makeWorkshopScreen({\n client: ctx.client,\n content: ctx.content,\n navigate: (modeId) => ctx.navigate(modeId),\n }),\n });\n\n // Games register their content types in their own setup() — before or after this one — so the\n // tab follows the registry instead of looking once.\n const sync = (): void =>\n ctx.features.setAvailable(\n \"workshop\",\n ctx.content.listTypes().length > 0,\n \"no installed game registers a content type (ctx.content.registerType)\",\n );\n sync();\n ctx.content.subscribe(sync);\n\n ctx.exposeToAgent({\n state: () => ({\n contentTypes: ctx.content.listTypes().map((type) => ({\n type: type.type,\n canPublish: Boolean(type.listLocal && type.capture),\n canOpen: Boolean(type.open),\n })),\n }),\n describeActions: {},\n });\n },\n});\n"
|
|
56
61
|
},
|
|
57
62
|
{
|
|
58
63
|
"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 SocialReportReason,\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_CONFIGURED: \"The Workshop is not set up in this game yet.\",\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}>\n {item.Title ||\n (item.Status === \"Uploading\" ? \"Unfinished upload\" : \"Untitled\")}\n </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.IsMine ? (\n <span style={badgeOwned}>Yours</span>\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: SocialReportReason[] = [\n \"Inappropriate\",\n \"Spam\",\n \"Stolen\",\n \"Broken\",\n \"Harassment\",\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<SocialReportReason>(\"Inappropriate\");\n\n const reload = useCallback(async () => {\n const r = await host.client.workshop.getContent(contentID);\n if (!r.ok) {\n setMessage(errorText(r.error));\n return;\n }\n setData(r.data);\n // Following the author is a social-layer edge on the PLAYER, not part of the card.\n const creator = r.data.Content?.CreatorUserID;\n if (creator && !r.data.Content?.IsMine) {\n const s = await host.client.social.getEntityStates(\"User\", [creator]);\n if (s.ok) setFollowing(s.data.Items?.[0]?.Following === true);\n }\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 {view.IsMine && (view.Access?.length ?? 0) > 0 && (\n <div style={muted}>\n Others get it: {(view.Access ?? []).map(accessLabel).join(\" · \")}\n </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.social.unlike(\"WorkshopItem\", contentID)\n : await host.client.social.like(\"WorkshopItem\", 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.social.unfavorite(\"WorkshopItem\", contentID)\n : await host.client.social.favorite(\"WorkshopItem\", 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.social.unfollow(\"User\", creator)\n : await host.client.social.follow(\"User\", 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 SocialReportReason)\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.social.report(\n \"WorkshopItem\",\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 border: \"1px solid #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 border: \"1px solid #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"
|
|
64
|
+
"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 SocialReportReason,\n WorkshopVisibility,\n} from \"@idosgames/core\";\nimport type {\n ContentRegistry,\n ContentTypeHandler,\n FeatureScreenProps,\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\n/** The Workshop as a tab of the lobby (a feature screen); `navigate` hands the screen to a game. */\nexport function makeWorkshopScreen(\n host: WorkshopHost,\n): ComponentType<FeatureScreenProps> {\n return function WorkshopScreen(): 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_CONFIGURED: \"The Workshop is not set up in this game yet.\",\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}>\n {item.Title ||\n (item.Status === \"Uploading\" ? \"Unfinished upload\" : \"Untitled\")}\n </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.IsMine ? (\n <span style={badgeOwned}>Yours</span>\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: SocialReportReason[] = [\n \"Inappropriate\",\n \"Spam\",\n \"Stolen\",\n \"Broken\",\n \"Harassment\",\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<SocialReportReason>(\"Inappropriate\");\n\n const reload = useCallback(async () => {\n const r = await host.client.workshop.getContent(contentID);\n if (!r.ok) {\n setMessage(errorText(r.error));\n return;\n }\n setData(r.data);\n // Following the author is a social-layer edge on the PLAYER, not part of the card.\n const creator = r.data.Content?.CreatorUserID;\n if (creator && !r.data.Content?.IsMine) {\n const s = await host.client.social.getEntityStates(\"User\", [creator]);\n if (s.ok) setFollowing(s.data.Items?.[0]?.Following === true);\n }\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 {view.IsMine && (view.Access?.length ?? 0) > 0 && (\n <div style={muted}>\n Others get it: {(view.Access ?? []).map(accessLabel).join(\" · \")}\n </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.social.unlike(\"WorkshopItem\", contentID)\n : await host.client.social.like(\"WorkshopItem\", 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.social.unfavorite(\"WorkshopItem\", contentID)\n : await host.client.social.favorite(\"WorkshopItem\", 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.social.unfollow(\"User\", creator)\n : await host.client.social.follow(\"User\", 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 SocialReportReason)\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.social.report(\n \"WorkshopItem\",\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 // A tab of the lobby: the lobby scrolls it and gives it the width.\n minHeight: 360,\n borderRadius: 16,\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 border: \"1px solid #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 border: \"1px solid #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
65
|
},
|
|
61
66
|
{
|
|
62
67
|
"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"
|
|
68
|
+
"content": "{\n \"id\": \"workshop\",\n \"type\": \"feature\",\n \"inBase\": true,\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
69
|
}
|
|
65
70
|
]
|
|
66
71
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dev-test-loop",
|
|
3
3
|
"description": "Run the боево (real) create→configure→generate→test→fix loop when building or improving a game feature on the iDosGames TypeScript SDK (@idosgames/core). Provision the DEV title ({TitleID}-DEV), implement the feature with the SDK and the per-module skills (character-system, store-system, quest-system, …), apply the title config for every module used, generate content (image/audio/3D) if needed, then run `npm run verify` (@idosgames/harness) against the DEV title and fix until it goes green. Use this whenever the user wants to build, add, or improve a game feature end-to-end, test a feature against the backend, close the loop, or otherwise wants the work to end in a green verify run against a real sandbox rather than just written code — even if they don't name the loop.",
|
|
4
|
-
"content": "---\nname: dev-test-loop\ndescription: >-\n Run the боево (real) create→configure→generate→test→fix loop when building or\n improving a game feature on the iDosGames TypeScript SDK (@idosgames/core).\n Provision the DEV title ({TitleID}-DEV), implement the feature with the SDK and\n the per-module skills (character-system, store-system, quest-system, …),\n apply the title config for every module used, generate content (image/audio/3D)\n if needed, then run `npm run verify` (@idosgames/harness) against the DEV title\n and fix until it goes green. Use this whenever the user wants to build, add, or\n improve a game feature end-to-end, test a feature against the backend, close the\n loop, or otherwise wants the work to end in a green verify run against a real\n sandbox rather than just written code — even if they don't name the loop.\n---\n\n# Dev test loop (iDosGames)\n\nYour unit of work is not \"write the code\" — it's **\"take the feature to a green\nбоевой (real) verify run against a DEV title.\"** You don't finish until the\nproject builds and the feature actually works against the backend sandbox.\n\nTesting is done **here, by you** (locally, via `npm run verify`) — there is no\nseparate test service. The same runner also powers AI Coder in the browser\n(
|
|
4
|
+
"content": "---\nname: dev-test-loop\ndescription: >-\n Run the боево (real) create→configure→generate→test→fix loop when building or\n improving a game feature on the iDosGames TypeScript SDK (@idosgames/core).\n Provision the DEV title ({TitleID}-DEV), implement the feature with the SDK and\n the per-module skills (character-system, store-system, quest-system, …),\n apply the title config for every module used, generate content (image/audio/3D)\n if needed, then run `npm run verify` (@idosgames/harness) against the DEV title\n and fix until it goes green. Use this whenever the user wants to build, add, or\n improve a game feature end-to-end, test a feature against the backend, close the\n loop, or otherwise wants the work to end in a green verify run against a real\n sandbox rather than just written code — even if they don't name the loop.\n---\n\n# Dev test loop (iDosGames)\n\nYour unit of work is not \"write the code\" — it's **\"take the feature to a green\nбоевой (real) verify run against a DEV title.\"** You don't finish until the\nproject builds and the feature actually works against the backend sandbox.\n\nTesting is done **here, by you** (locally, via `npm run verify`) — there is no\nseparate test service. The same runner also powers AI Coder in the browser\n(the dashboard, next to the live preview), so scenarios you write are reusable.\n\n## The loop\n\n1. **Get the DEV title.** Every live title has a paired DEV sandbox `{TitleID}-DEV`,\n isolated from prod. Provision/reuse it once, then work entirely in DEV:\n\n ```bash\n curl -s -X POST \"$IGE_API_URL/v2/ai-coder/EnsureDevTitle\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\\\"AuthToken\\\":\\\"$IGE_AUTH_TOKEN\\\",\\\"TitleID\\\":\\\"$IGE_TITLE_ID\\\"}\"\n # → { \"Data\": { \"parentTitleId\": \"...\", \"devTitleId\": \"ABCD1234-DEV\", \"created\": true } }\n ```\n\n Use `devTitleId` for everything below. Never test against the prod title.\n\n2. **Understand → load skills.** Identify which SDK services and title modules the\n feature touches, and read those skills first (`character-system`, `store-system`,\n `currency-system`, `quest-system`, `lootbox-system`, `leaderboard-system`,\n `season-system`, …). Don't guess SDK signatures — the skills are canonical.\n\n3. **Implement.** Write the game code on `@idosgames/core` following the loaded\n skills and the template's conventions. Edit minimally and in place.\n\n4. **Configure the title.** For every module the feature uses, apply settings to\n the **DEV** title (currencies, items, characters, store SKUs, seasons, …). Code\n and config must agree — ids/keys referenced in code must exist in the config.\n\n5. **Generate content (if needed).** Create missing assets (icons, audio, 3D,\n sprites) via the generation tools, wait for completion, put the real URLs into\n the config. No placeholders.\n\n6. **Boевой тест — mandatory.** Run the real verify against the DEV title:\n\n ```bash\n IDOS_TITLE_ID=\"ABCD1234-DEV\" npm run verify\n # optional: IDOS_BASE_URL=<runtime> IDOS_DEVICE_ID=<stable id>\n ```\n\n This does a device-id login and runs SDK scenarios against the DEV title,\n printing structured JSON and exiting 0 (green) / 1 (failures). Also run the\n project build (`npm run build`) so compile/type errors surface. Never call the\n task done without a green verify in this turn.\n\n7. **Fix loop.** On failures — read the structured result (build error, `reason`,\n `error`, `failedStep`), find the root cause, and fix the right layer:\n - build/type error → fix the **code**;\n - missing id/key → fix the **DEV title config**;\n - `reason: \"server\"` / failed SDK step → fix call logic, auth, or code↔config\n drift.\n Re-run verify. Repeat until fully green. Don't ask for confirmation between\n iterations — fix it yourself. Start each iteration with:\n `Провалилось: <что> → правлю: <что>`.\n\n## Writing scenarios\n\nThe default `npm run verify` runs the `auth-smoke` scenario (proves login works).\nFor a real feature test, add a scenario for it, using the per-module skill as the\nsource of truth for the calls. Pattern (see `@idosgames/harness` README and\n`packages/harness/src/scenarios.ts`):\n\n```ts\nimport type { Scenario } from \"@idosgames/harness\";\n\nexport const unlockThenUpgrade: Scenario = {\n name: \"unlock-then-upgrade\",\n run: async (ctx) => {\n await ctx.call(\"login\", () => ctx.client.auth.loginWithDeviceID());\n await ctx.call(\"defs\", () =>\n ctx.client.character.getCharacterDefinitions(),\n );\n await ctx.call(\"unlock\", () =>\n ctx.client.character.unlockCharacter(\"Knight\"),\n );\n const state = await ctx.call(\"state\", () =>\n ctx.client.character.getUserCharacters(),\n );\n ctx.expect(\n (state.Characters?.[\"Knight\"]?.Power ?? 0) > 0,\n \"Knight Power should be > 0\",\n );\n },\n};\n```\n\n`ctx.call` asserts `OperationResult.ok` and returns `data`; a failed SDK call\nbecomes a structured step failure with `reason`/`error`. `ctx.expect` asserts\nstate. Between big runs, reset the DEV title with\n`POST $IGE_API_URL/v2/ai-coder/ResetDevTitle {AuthToken, TitleID: devTitleId}`\n(full wipe + clean re-seed) so runs are deterministic.\n\n## Definition of Done (all required)\n\n- Project builds with no errors.\n- Every touched module is configured on the **DEV** title, consistent with the code.\n- All content referenced by code/config exists (real URLs, not placeholders).\n- `npm run verify` is **green** against the DEV title; you cited the passing result.\n\n## Prerequisites (env)\n\n- `IGE_API_URL` — management backend base (AICoder + config; `v2/ai-coder/*`).\n- `IGE_AUTH_TOKEN` — publisher auth token.\n- `IGE_TITLE_ID` — the prod title whose DEV sandbox you build against.\n- `IDOS_BASE_URL` — runtime engine base for the SDK (optional; defaults to\n `api.idosgames.com`).\n\n## References\n\n- Harness usage & result shape: `packages/harness/README.md`.\n- Per-module SDK recipes: the `*-system` skills in this folder.\n- Loop prompts (backend + Claude Code): the playbook in the backend repo,\n `Core/Controllers/v2/AICoder/AGENTIC_LOOP_PROMPTS.md`.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-compose-modules",
|
|
3
|
-
"description": "
|
|
4
|
-
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n
|
|
3
|
+
"description": "Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards, rewards…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system, add a second game, switch between the lobby and game modes, share currency/inventory across modules, or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi, shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module).",
|
|
4
|
+
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards,\n rewards…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this\n whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system,\n add a second game, switch between the lobby and game modes, share currency/inventory across modules,\n or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi,\n shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared\n state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single\n module).\n---\n\n# Putting an app together: the base + modules\n\nEvery project is **the base + modules**. The base (`src/base/`, from the host scaffold) is sign-in and\nthe lobby; everything else is a module in `src/modules/<id>/`:\n\n- **systems** (`type: feature`, `inBase: true` in the catalog) — the shop, heroes, inventory,\n leaderboards, rewards, craft, marketplace, deals, events, season, friends, chat, invite, arena,\n profile, tutorials, workshop. Each registers its screen as a **feature** → a tab of the lobby. The\n platform installs all of them with the base into every new project; remove the ones the app does not\n need.\n- **games** (`type: template`) — board-game, idle-rpg, voxelcraft, mini-game, or the game's own. Each\n registers a **route** → a \"Play\" button in the lobby.\n\n## Register modules\n\n```ts\n// src/modules.ts — the platform regenerates it; by hand it is imports + the array\nexport const modules: Module[] = [\n storeModule, // systems: lobby tabs\n characterModule,\n leaderboardModule,\n boardGameModule, // games: \"Play\"\n idleRpgModule,\n];\n```\n\n`src/main.tsx` mounts `[baseModule, ...modules]`. After sign-in the player lands in the lobby\n(`src/app.config.ts` `start` can send them straight into a game). \"Play\" switches the host to the\ngame's mode: only the active mode's scene is mounted and ticking; the rest are suspended (their RAF\nstops). This is why two different engines (Three + Phaser) live in one project — they never render\nat the same time. Over a game the base draws only a small \"To the lobby\" button.\n\n- A system tab is hidden while the title has nothing for it (no store offers, no heroes…): the module\n registers with `available: false` and calls `ctx.features.setAvailable(id, ok, reason)`.\n- `ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type);\n the lobby builds \"Play\" from the `game` ones. Switch with `ctx.navigate(id)` (`\"lobby\"` is the base).\n- Open another system's screen by id: `ctx.features.open(\"store\")` — from a game too (the host shows\n it in a window while the lobby is off screen). Never import another module.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode and in the shop — same\n `client.currency`, same cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files or `src/base/`. The client and the event bus are the seams\n between them — which is what keeps a module reusable in another game. Code several of THIS game's\n modules need (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a\n module (see idosgames-project-structure).\n\n## Shared chrome: roles, and the base takes them\n\nBalances, the crypto wallet, the status line, \"Log out\" / player ID, the menu of features and the\nswitch between modes are **shared-UI roles** (`currency-bar | wallet | status | account | menu |\nmodes`). A module that draws one for EVERY mode declares it statically on the module object —\nthe base does:\n\n```ts\ndefineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n /* the lobby: balances, wallet, profile, tabs, \"Play\" */\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider wins,\na conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`. A game draws its own\ncopy only while nobody took the role. So:\n\n- Next to the base a game hides its own balances and wallet by itself. Do NOT edit a catalog game to\n remove them: it does that itself, and an edit marks it customized (no more catalog updates).\n- `status` — the line of a game's own actions (\"Rolling…\") — stays with each game.\n- A game installed without a base stays complete — nobody owns a role, it draws everything, and the\n host draws its own nav of routes, ☰ and \"Log out\" (those are the host-drawn roles `modes`, `menu`,\n `account`).\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n\n**Layout is the host's job.** A game's overlay fills the screen under the base's small button; a\nscene's own DOM HUD can keep clear of host chrome with the CSS variables on the host root —\n`top: calc(var(--idos-safe-top, 0px) + 8px)`, `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)`\n(voxelcraft's hotbar does this). Both are 0 when nothing is pinned. `hud`-slot panels are wrapped in\n`display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let clicks\nthrough to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference app\n\nThe base + the systems + `board-game` + `idle-rpg`:\n\n- sign-in → the lobby: balances, wallet and profile on top, the systems as tabs, \"Play\" with two games;\n- \"Play\" → one engine scene mounted at a time; the base's button returns to the lobby;\n- the games hide their own balances and wallet (the lobby has them); their status lines stay theirs;\n- currency changed in one mode is immediately visible in the others and in the lobby; levelling a hero\n in Idle RPG shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-getting-started",
|
|
3
|
-
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in
|
|
4
|
-
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them. Mixing two or more templates → also add `game-hud`: one balance bar, wallet and status line\n for every mode, and the templates hide their own copies by themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
|
|
3
|
+
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and the games (board-game, idle-rpg, voxelcraft, mini-game, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as the project grows). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
|
|
4
|
+
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in\n + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and\n the games (board-game, idle-rpg, voxelcraft, mini-game, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Base** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/base`): sign-in and the lobby in `src/base/`, `src/app.config.ts` (start in the lobby or\n a game), an empty `src/modules.ts` → an empty lobby.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`. The platform's standard set is every module with `inBase: true` (the\n systems: shop, heroes, inventory, leaderboards, rewards…) plus the game(s).\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — sign-in → the lobby: the systems' screens are its tabs (a tab hides itself while the\n title has nothing for it), every game gets a \"Play\" button, and a small button over a game returns\n to the lobby. The lobby draws the balances and the wallet; games hide their own copies by\n themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
|
|
5
5
|
"references": []
|
|
6
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
|
|
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### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\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`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith game-hud installed the HUD still draws the account and the mode hides its copy.\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, mini-game, store, character, workshop…), or asks about defineModule, registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable), ctx.modes, 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, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\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: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (`src/base/` — sign-in and the lobby):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby shows a \"Play\" button for it, and\n over it the base draws only a small \"To the lobby\" button;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, Screen })`. A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in.\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### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\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), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. 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`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\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
|
}
|