@idosgames/mcp 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +4 -4
- package/registry/index.json +46 -26
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/game-hud.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +136 -36
- package/registry/skills/ai-generation-system.json +11 -0
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/voxelcraft-worlds.json +6 -0
- package/registry/skills/workshop-system.json +6 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-generation-system",
|
|
3
|
+
"description": "Add InApp AI generation to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text generation (optionally with images in — e.g. build a voxel world from a picture the player uploads), image generation and image editing, video, speech (TTS), music and 3D models — every generation is a job the caller waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at runtime in a game built on the iDosGames TS SDK or its templates (board-game, idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels / worlds, voice lines, generated music, 3D props — or touches client.ai, AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature, AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode, isAIGenerationTerminal or parseAIErrorCode — even if they don't name the module.",
|
|
4
|
+
"content": "---\nname: ai-generation-system\ndescription: >-\n Add InApp AI generation to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text\n generation (optionally with images in — e.g. build a voxel world from a\n picture the player uploads), image generation and image editing, video,\n speech (TTS), music and 3D models — every generation is a job the caller\n waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at\n runtime in a game built on the iDosGames TS SDK or its templates (board-game,\n idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels /\n worlds, voice lines, generated music, 3D props — or touches client.ai,\n AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature,\n AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode,\n isAIGenerationTerminal or parseAIErrorCode — even if they don't name the\n module.\n---\n\n# AI generation system (iDosGames TS SDK)\n\nThe AI module lets a **player** generate content at runtime — text, images,\nvideo, speech, music, 3D — through the game backend. The title's publisher\ndefines **features** (each with a stable `FeatureKey` such as `\"npc-chat\"`,\n`\"voxel-world\"`, `\"skin-gen\"`) in the dashboard or through the backend\ntitle-config MCP (`save_ai`). A feature fixes the modality, the model, the\nsystem prompt, the limits and the in-game price for the player. The game only\nnames the feature and sends the player's input.\n\nIt is **server-authoritative and publisher-paid**:\n\n- The client **never** picks a model, a prompt or a price. There is no such\n field on the wire, and there must not be: generation runs on the publisher's\n credits, so a client that chose the model would choose the publisher's bill.\n- The provider cost is charged to the **publisher's** credits. On top of that\n the **player** may pay in-game resources (the feature's `PriceOptions`) —\n returned on the generation as `Charge` and applied to the local cache by the\n SDK.\n- Failures after a charge are refunded in full by the server\n (`PlayerRefunded: true` on the generation).\n\nThis skill is for **using** `client.ai`. Creating or changing features is\npublisher configuration (dashboard / `save_ai`); the client cannot do it.\n\n## Key data entities\n\n1. **`AIPublicDefinitions`** (per title) — the sanitized feature list from\n `getDefinitions()`: for each `FeatureKey`, its `Modality`, `Limits`,\n `PriceOptions`, what the client may send\n (`AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes`,\n `MaxImages`, `AllowEdit`, `AllowReferenceImage`, `MaxPromptChars`,\n `LockBehavior`, `HistoryMessages`, `MaxTokens`) and UI defaults. No prompt,\n no model. Disabled features are simply absent. Cached as config section\n `\"AI\"` — it is **not** part of the title config, you must call\n `getDefinitions()`.\n2. **`AIGenerationView`** — one generation as the player sees it:\n `GenerationID`, `FeatureKey`, `Modality`, `Status`\n (`\"pending\" | \"running\" | \"completed\" | \"failed\"`), `Progress` (0..100, async\n jobs), `OutputText`, `Assets[]` (`{ Url, ContentType, Variant }`),\n `Error { Code, Message }`, `Charge`, `PlayerRefunded`, `CreatedAt`,\n `UpdatedAt`.\n3. **No player-state slot.** Limit counters live on the server; history is\n `listGenerations()` / `getGeneration()`.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst ai = client.ai; // the AIService\nconst defs = await ai.getDefinitions();\n```\n\nEvery method needs a session; without one it returns\n`{ ok: false, reason: \"unauthorized\" }` — it never throws.\n\n## Methods\n\nAll return `Promise<OperationResult<T>>`. The `options` argument of every\ngenerate method is `{ relatedEntityID?, selectedOptionID? }` (see\nIdempotency).\n\n| Method | Purpose | `data` |\n| --------------------------------------------- | --------------------------------------------------------------- | ---------------------- |\n| `getDefinitions()` | The title's features (sanitized). | `AIPublicDefinitions` |\n| `generateText(featureKey, input, options?)` | Submit a text job → `pending`. | `AIGenerationView` |\n| `getText(generationID)` | Poll a text job. | `AIGenerationView` |\n| `generateImage(featureKey, input, options?)` | Submit an image job → `pending`. | `AIGenerationView` |\n| `editImage(featureKey, input, options?)` | Submit an image edit (feature with `AllowEdit`) → `pending`. | `AIGenerationView` |\n| `getImage(generationID)` | Poll an image / edit job. | `AIGenerationView` |\n| `generateVideo(featureKey, input, options?)` | Submit a video job → `running`. | `AIGenerationView` |\n| `getVideo(generationID)` | Poll a video job. | `AIGenerationView` |\n| `generateAudio(featureKey, input, options?)` | Submit a speech (TTS) job → `pending`. | `AIGenerationView` |\n| `getAudio(generationID)` | Poll a speech job. | `AIGenerationView` |\n| `generateMusic(featureKey, input, options?)` | Submit a music job → `pending`. | `AIGenerationView` |\n| `getMusic(generationID)` | Poll a music job. | `AIGenerationView` |\n| `generateThreeD(featureKey, input, options?)` | Submit a 3D job → `running`. | `AIGenerationView` |\n| `getThreeD(generationID)` | Poll a 3D job. | `AIGenerationView` |\n| `waitForGeneration(view or id, options?)` | Poll the job with its own action until it is final. | `AIGenerationView` |\n| `listGenerations(query?)` | History page (`{ Modality, FeatureKey, Status, Skip, Limit }`). | `AIGenerationListView` |\n| `getGeneration(generationID)` | One generation (history). | `AIGenerationView` |\n| `isTerminal(status)` | `true` once polling can stop. | `boolean` |\n\n**Every generation is a job** (since 13.09.2026). A generate call only submits\nit and answers `pending` (text, image, edit, speech, music — queued on the\nserver) or `running` (video, 3D — submitted to the provider); the server never\nwaits for the model inside the request. Get the result with\n`waitForGeneration(view)` — it polls the right action until the status is\nfinal and returns the final view.\n\n### Two different kinds of failure — handle both\n\n```ts\nimport { parseAIErrorCode, AIErrorCode } from \"@idosgames/core\";\n\nconst res = await client.ai.generateImage(\"skin-gen\", { Prompt: text });\n\nif (!res.ok) {\n // 1. The REQUEST was refused (nothing generated, nothing charged) — or a\n // transport problem. Server refusals arrive as \"AI_CODE: safe message\".\n const code = parseAIErrorCode(res.error);\n if (code === AIErrorCode.LimitReached)\n return showToast(\"Come back tomorrow!\");\n if (code === AIErrorCode.Unavailable)\n return showToast(\"Not available right now.\");\n if (res.reason === \"connection\") return offerRetry(); // see Idempotency\n return showToast(res.error);\n}\n\nconst done = await client.ai.waitForGeneration(res.data);\nif (!done.ok) return showToast(\"Still working — check back later.\"); // timed out / aborted; the job keeps running\n\nif (done.data.Status === \"failed\") {\n // 2. The generation RAN and failed. The calls succeeded; PlayerRefunded says\n // the player got the price back. Show Error.Message, branch on Error.Code.\n return showToast(done.data.Error?.Message ?? \"Generation failed.\");\n}\n\nshowImage(done.data.Assets?.[0]?.Url);\n```\n\n`reason` is one of `\"client\"` (missing `featureKey` / `generationID`),\n`\"unauthorized\"`, `\"throttled\"`, `\"connection\"`, `\"validation\"`, `\"server\"`.\n\n## Recipes\n\n### AI NPC dialogue (text with history)\n\n```ts\nconst history: AIChatMessage[] = [];\n\nasync function say(playerLine: string) {\n const res = await client.ai.generateText(\"npc-chat\", {\n Prompt: playerLine,\n Messages: history, // earlier turns; the server keeps the feature's HistoryMessages last ones\n });\n if (!res.ok) return null;\n const done = await client.ai.waitForGeneration(res.data, {\n intervalMs: 1000,\n });\n if (!done.ok || done.data.Status !== \"completed\") return null;\n\n history.push({ Role: \"user\", Content: playerLine });\n history.push({ Role: \"assistant\", Content: done.data.OutputText ?? \"\" });\n return done.data.OutputText;\n}\n```\n\nThe NPC's persona is the feature's system prompt on the server — don't put it\nin `Prompt`. A reply takes a few seconds: show a \"thinking…\" state and disable\nthe send button. `intervalMs: 1000` shows a short reply quickly; the wait slows\ndown on its own for a long answer.\n\n### Picture in, text out — build a voxel world from a picture\n\nFor a Text feature with `AllowImages`, attach images to the prompt. Check the\nfeature's constraints before uploading so the player gets an instant message\ninstead of a round trip:\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"voxel-world\"] : undefined;\n\nasync function fileToInput(file: File): Promise<AIInputImage | string> {\n if (!feature?.AllowImages) return \"This feature does not take pictures.\";\n const types = feature.AllowedImageTypes ?? [\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n ];\n if (!types.includes(file.type)) return \"Use a PNG, JPEG or WebP picture.\";\n if (feature.MaxInputImageBytes && file.size > feature.MaxInputImageBytes)\n return \"The picture is too large.\";\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.onerror = () => reject(r.error);\n r.readAsDataURL(file);\n });\n return { Base64: dataUrl, ContentType: file.type }; // a data-URL is accepted as is\n}\n\nconst image = await fileToInput(file);\nif (typeof image === \"string\") return showToast(image);\n\nconst res = await client.ai.generateText(\"voxel-world\", {\n Prompt: \"Turn this picture into a voxel world. Answer with JSON only.\",\n Images: [image],\n});\nif (!res.ok) return showToast(res.error);\n\n// A whole world is a long answer (minutes): wait with progress, stop when the screen closes.\nconst done = await client.ai.waitForGeneration(res.data, {\n signal: screenAbort.signal,\n});\n\nconst world = parseWorldJson(done.ok ? done.data.OutputText : null); // validate it — it is model output\n```\n\nThe expected output format (\"JSON with blocks: [...]\") belongs in the feature's\nsystem prompt, so every player gets the same contract. Always validate the\nparsed structure: it is model output, not trusted data.\n\n### Generate an image, then edit it\n\n```ts\nconst img = await client.ai.generateImage(\"skin-gen\", {\n Prompt: \"a red dragon armour skin\",\n Size: \"1024x1024\", // ignored if the feature has LockBehavior\n});\nif (!img.ok) return;\nconst made = await client.ai.waitForGeneration(img.data);\nif (!made.ok || made.data.Status !== \"completed\") return;\nconst url = made.data.Assets?.[0]?.Url;\n\n// Feed a generated asset back in by URL — only assets THIS title generated are accepted.\nconst edit = await client.ai.editImage(\"skin-gen\", {\n Prompt: \"make it blue\",\n Images: [{ AssetUrl: url }],\n});\nconst edited = edit.ok ? await client.ai.waitForGeneration(edit.data) : edit;\n```\n\n### Waiting for a job — `waitForGeneration`\n\nNothing polls on its own (platform convention, as with chat): each poll is an\nAPI call billed to the publisher, so wait only while the player needs the\nresult.\n\n```ts\nconst job = await client.ai.generateVideo(\"trailer\", {\n Prompt: \"a dragon over a castle\",\n Seconds: 8,\n});\nif (job.ok) {\n const done = await client.ai.waitForGeneration(job.data, {\n signal: screenAbort.signal, // stop when the screen closes\n onUpdate: (v) => renderProgress(v.Progress), // 0..100 when the provider reports it\n });\n if (done.ok && done.data.Status === \"completed\")\n playVideo(done.data.Assets?.[0]?.Url);\n}\n```\n\n- Options: `intervalMs` (first pause, default 2000; grows ×1.5 up to\n `maxIntervalMs`, default 5000), `timeoutMs` (default 30 minutes), `signal`,\n `onUpdate`. A dropped connection does not end the wait; a timeout or the\n signal ends it with `reason: \"client\"` — the job keeps running on the server.\n- An abandoned job is not lost: save its `GenerationID` and resume later with\n `waitForGeneration(id)` (it reads the generation once to learn its modality),\n or find it in `listGenerations()`.\n- Polling by hand: use the action of the modality (`getText`, `getImage`,\n `getAudio`, `getMusic`, `getVideo`, `getThreeD`) — only it moves a provider\n job forward. `isTerminal` treats anything other than `pending` / `running`\n as final, so a status added later never makes a loop spin forever.\n- 3D `Assets` may hold several files (e.g. a model and a preview) — pick by\n `Variant` / `ContentType`.\n\n### Speech and music\n\n```ts\nconst voice = await client.ai.generateAudio(\"npc-voice\", {\n Text: \"Welcome, traveller!\",\n});\nconst spoken = voice.ok ? await client.ai.waitForGeneration(voice.data) : voice;\nif (spoken.ok && spoken.data.Status === \"completed\")\n new Audio(spoken.data.Assets?.[0]?.Url).play();\n\nconst track = await client.ai.generateMusic(\"level-music\", {\n Prompt: \"calm lofi for a farm level\",\n});\nconst music = track.ok ? await client.ai.waitForGeneration(track.data) : track;\n```\n\n### Idempotency and retries\n\nEach generate call sends a `RelatedEntityID`. Without `options` the SDK mints\na fresh one per call, so **two calls are two paid generations**. A retry with\nthe **same** key returns the original generation and is never charged twice.\n\nGenerate calls use a long timeout and **no automatic transport retry** (a\nresend could reach the server while the first attempt is still running). A\nnetwork failure therefore surfaces as `reason: \"connection\"`. To retry\nsafely, own the key:\n\n```ts\nconst key = crypto.randomUUID();\nlet res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n});\nif (!res.ok && res.reason === \"connection\")\n res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n }); // same generation\n```\n\nA key must be 1–128 characters of `A–Z a–z 0–9 _ -` (a UUID fits). Anything\nelse fails locally with `reason: \"client\"`: the server would strip the other\ncharacters and cut at 128, and two different keys could silently become one\ngeneration. Don't add the feature or player to the key — the server already\nscopes keys by title and player.\n\n### Pay with a specific option\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"skin-gen\"] : undefined;\n// feature.PriceOptions: Record<OptionID, PriceOption> — empty/absent = free for the player.\nawait client.ai.generateImage(\"skin-gen\", input, { selectedOptionID: \"gems\" });\n```\n\nOmit `selectedOptionID` to pay with the first available option. Filter options\nby platform with `client.checkout` before showing them, like any price.\n\n### Pay with a store purchase (IAP)\n\nAn option may be a store purchase (a `Purchase` entry): **one purchase pays for\nexactly one generation.** Buy the consumable in the native store, keep the\ntransaction PENDING, and send its receipt:\n\n```ts\nconst res = await client.ai.generateImage(\"skin-gen\", input, {\n selectedOptionID: \"store\",\n payment: { Store: \"GooglePlay\", Receipt: receipt },\n});\nconst done = res.ok ? await client.ai.waitForGeneration(res.data) : res;\nif (done.ok && done.data.PaymentSettled === true) finishStoreTransaction(); // consume now\n```\n\n- `PaymentSettled: true` — the generation is ready, the purchase is counted:\n finish (consume) the store transaction.\n- `PaymentSettled: false` on a job still in progress (every generation until it\n completes) — keep the transaction pending and finish it when a poll reports `true`.\n- `PaymentSettled: false` on a **failed** generation — keep the transaction and\n retry with the SAME receipt and a NEW `relatedEntityID`: the retry is free.\n- Re-sending a receipt that already paid returns that generation — it never\n pays for a second one. A publisher out of credits refuses BEFORE the receipt\n is taken, so the purchase is not burnt.\n\n### History\n\n```ts\nconst page = await client.ai.listGenerations({\n Modality: \"Image\",\n Status: \"completed\",\n Limit: 20,\n});\nconst one = await client.ai.getGeneration(generationID);\n```\n\nHistory reads never change the cache and emit no events.\n\n## Events\n\nSubscribe with `client.on(...)` (returns an unsubscribe fn):\n\n- `ai:definitionsLoaded` → `AIPublicDefinitions`\n- `ai:generationUpdated` → `AIGenerationView` — every generate and poll response\n- `ai:generationCompleted` → `AIGenerationView` — a generate/poll came back `completed`\n- `ai:generationFailed` → `AIGenerationView` — a generate/poll came back `failed`\n\nApplying a `Charge` also fires the usual resource events\n(`user:virtualCurrencyUpdated`, `user:inventoryUpdated`,\n`user:eventTokenUpdated`, `user:anyUpdated`).\n\n## Error codes\n\n`AIErrorCode` constants; a refusal's `error` reads `\"CODE: message\"`\n(`parseAIErrorCode`), a failed generation carries `Error.Code`.\n\n| Code | Meaning | What to show |\n| ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ |\n| `AI_DISABLED` | AI is off for the title | hide AI UI |\n| `AI_FEATURE_NOT_FOUND` / `AI_FEATURE_DISABLED` | no such / disabled feature | hide the entry point; refresh `getDefinitions()` |\n| `AI_WRONG_ACTION` | action ≠ feature modality | a bug in the game: fix the call |\n| `AI_NOT_AVAILABLE_FOR_PLAYER` | the feature's audience gate excludes this player | hide the entry point |\n| `AI_FEATURE_CLOSED` | outside the feature's schedule | \"not available now\" |\n| `AI_LIMIT_REACHED` / `AI_COOLDOWN` | the feature's `Limits` | \"come back later\" |\n| `AI_BAD_INPUT` | too long, too many / too large / wrong images, a foreign `AssetUrl` | the message |\n| `AI_PROMPT_REJECTED` | blocked terms | ask to rephrase |\n| `AI_UNAVAILABLE` | the publisher's side (credits, plan, caps) — reason deliberately hidden | a neutral \"not available right now\" |\n| `AI_GENERATION_FAILED` | the provider failed; everything charged is returned | \"try again\" |\n| `AI_GENERATION_NOT_FOUND` | unknown `GenerationID` | drop the stored id |\n\nTreat any other `AI_…` code generically — newer backends may add codes.\n\n## Gotchas\n\n- **A generate response is not the result.** It is `pending` / `running`; wait\n with `waitForGeneration`, then check the final `Status` — a failed generation\n is a successful call (`result.ok` ≠ \"generated\").\n- **One generate per action at a time.** A second `generateImage` while one is\n in flight is rejected with `reason: \"throttled\"` (the server serializes\n them per player and action anyway). Disable the button while waiting. Polls\n of _different_ jobs are not throttled against each other.\n- **The Charge is handled for you.** It is applied once, from the generate\n response. A generation with `PlayerRefunded: true` (failed after charging;\n the server returned the price) is never applied — its `Charge` still shows\n what was taken — and if this session had already applied it (every job is\n charged at submit), the SDK re-reads the inventory before emitting the\n event. A failed generation WITHOUT `PlayerRefunded` keeps its charge. Don't\n apply `Charge` yourself.\n- **Behaviour overrides are optional and may be ignored.** Size, quality,\n voice, image count… apply only when the feature has no `LockBehavior`;\n `MaxTokens` can only _lower_ the feature's value; `MaxPromptChars` counts the\n history too.\n- **Input images are checked by file signature**, not by the declared\n `ContentType`; `AssetUrl` must point to an asset this title generated.\n- **Don't cache `Assets[].Url` forever as the only copy** if the player owns\n the result — store the `GenerationID` and re-read it when needed.\n- **Unknown modality on an old SDK** falls back to `\"Text\"` when parsing\n definitions; the server still enforces the real one (`AI_WRONG_ACTION`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every request/response\nfield, the feature fields and their defaults (no platform caps — only the\nmodel's own limits), the server-side order of checks,\nand how charges, refunds and async jobs behave.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# AI generation data model — reference\n\nFull shapes of the requests, responses and the feature view, the hard caps a\nfeature cannot exceed, the order in which the server checks a request, and how\ncharges, refunds and async jobs behave. Everything is **strictly typed** and\nexported from `@idosgames/core` (`AIRequest`, `AITextInput`, …,\n`AIGenerationView`, `AIPublicDefinitions`, `AIPublicFeature`). Response schemas\nare not `.strict()` — fields the backend adds later still parse. Field names\nare PascalCase (straight from the backend JSON). Source of truth on the\nserver: `IDosGamesSDK/API/Client/v2/AI/Models/AIRequest.cs` and\n`AIDefinitions.cs` in `iDos_Games_Engine`.\n\n## Contents\n\n- [Route](#route)\n- [Request](#request)\n- [Responses](#responses)\n- [AIPublicFeature — what the client may send](#aipublicfeature--what-the-client-may-send)\n- [Hard caps](#hard-caps)\n- [Server order of checks](#server-order-of-checks)\n- [Charges, refunds, idempotency](#charges-refunds-idempotency)\n- [Jobs](#jobs)\n- [What the publisher configures](#what-the-publisher-configures)\n\n---\n\n## Route\n\n`POST {baseUrl}/api/v2/{titleID}/Client/AI/{Action}/{userID}` with\n`Authorization: Bearer {ClientSessionTicket}`; envelope\n`{ Success, Error?, Data }`. Actions (`AIAction`): `GetDefinitions`,\n`GenerateText`, `GetText`, `GenerateImage`, `EditImage`, `GetImage`,\n`GenerateVideo`, `GetVideo`, `GenerateAudio`, `GetAudio`, `GenerateMusic`,\n`GetMusic`, `GenerateThreeD`, `GetThreeD`, `ListGenerations`, `GetGeneration`.\n\nSDK transport settings: generate actions — timeout 200 s (they only submit a\njob; a video / 3D submit reaches the provider), **no** automatic transport\nretry; reads — the default 12 s with retries. Poll/read calls of one\ngeneration are throttled per generation, generate calls per action.\n\n## Request\n\n```ts\ninterface AIRequest extends BaseRequest {\n FeatureKey?: string; // required by every generate action\n SelectedOptionID?: string; // PriceOption.OptionID; absent = first available\n Text?: AITextInput; // GenerateText\n Image?: AIImageInput; // GenerateImage\n ImageEdit?: AIImageEditInput; // EditImage\n Video?: AIVideoInput; // GenerateVideo\n Audio?: AIAudioInput; // GenerateAudio\n Music?: AIMusicInput; // GenerateMusic\n ThreeD?: AIThreeDInput; // GenerateThreeD\n GenerationID?: string; // GetText / GetImage / GetAudio / GetMusic / GetVideo / GetThreeD / GetGeneration\n Query?: AIHistoryQuery; // ListGenerations\n // BaseRequest: UserID, ClientSessionTicket, BuildKey, RelatedEntityID (idempotency key), …\n}\n\ninterface AIInputImage {\n // exactly ONE source\n Base64?: string; // raw base64 or a data-URL\n ContentType?: string; // MIME of Base64\n AssetUrl?: string; // Url of an asset THIS title generated\n}\n\ninterface AITextInput {\n Prompt?: string;\n Messages?: { Role: \"user\" | \"assistant\"; Content: string }[]; // history\n Images?: AIInputImage[]; // only with AllowImages; attached to the last message\n MaxTokens?: number; // can only LOWER the feature's MaxTokens\n}\n\ninterface AIImageInput {\n Prompt?: string;\n Size?: string; // \"1024x1024\"\n Quality?: string; // \"low\" | \"medium\" | \"high\"\n Format?: string; // \"png\" | \"jpeg\" | \"webp\"\n Background?: string; // \"transparent\" | \"opaque\" | \"auto\"\n N?: number; // number of images, capped by MaxImages; absent/0 = 1\n}\n\ninterface AIImageEditInput {\n Prompt?: string;\n Images?: AIInputImage[];\n Size?: string;\n Format?: string;\n}\ninterface AIVideoInput {\n Prompt?: string;\n Size?: string;\n Seconds?: number;\n ReferenceImage?: AIInputImage;\n}\ninterface AIAudioInput {\n Text?: string;\n Voice?: string;\n Format?: string;\n} // \"mp3\" | \"wav\" | \"opus\"\ninterface AIMusicInput {\n Prompt?: string;\n Format?: string;\n} // \"mp3\" | \"wav\"\ninterface AIThreeDInput {\n Prompt?: string;\n Image?: AIInputImage;\n ArtStyle?: string;\n Format?: string;\n}\n// ArtStyle \"realistic\" | \"sculpture\"; Format \"glb\" | \"fbx\" | \"obj\" | \"usdz\"\n\ninterface AIHistoryQuery {\n Modality?: AIModality;\n FeatureKey?: string;\n Status?: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Skip?: number;\n Limit?: number; // 1..50, server default 20\n}\n```\n\nThere is intentionally **no** `Model`, prompt template or price field.\n\n## Responses\n\n```ts\ninterface AIGenerationView {\n GenerationID: string;\n FeatureKey?: string;\n Modality?: \"Text\" | \"Image\" | \"Video\" | \"Audio\" | \"Music\" | \"ThreeD\";\n Status: string; // \"pending\" | \"running\" | \"completed\" | \"failed\"\n Progress?: number; // 0..100, async jobs, when the provider reports it\n OutputText?: string; // Text\n Assets?: { Url?: string; ContentType?: string; Variant?: string }[];\n Error?: { Code?: string; Message?: string }; // failed generations; safe text, never vendor text\n Charge?: ResourceOperation; // in-game resources taken from the player\n PlayerRefunded?: boolean; // true = failed after charging, Charge was returned\n CreatedAt?: string; // ISO\n UpdatedAt?: string;\n}\n\ninterface AIGenerationListView {\n Data?: AIGenerationView[];\n Skip?: number;\n Limit?: number;\n}\n\ninterface AIPublicDefinitions {\n Enabled?: boolean; // master switch; false = every generate fails\n Features?: Record<string, AIPublicFeature>; // key = FeatureKey; disabled features are omitted\n}\n```\n\nNo model, usage or publisher price is ever returned.\n\n## AIPublicFeature — what the client may send\n\n| Field | Modality | Meaning |\n| ------------------------------------------------------------------------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------ |\n| `Modality` | all | which generate action the feature accepts |\n| `Async` | all | always `true` — every generation is a job; kept for old clients |\n| `Limits` | all | per-player anti-farm limits (`DailyCap`, `TotalCap`, `CooldownSeconds`, …; 0 = none) |\n| `PriceOptions` | all | the player's price per generation; empty = free; resources and/or ONE store purchase |\n| `Tags` | all | UI grouping only |\n| `HistoryMessages` | Text | how many history messages are kept (0 = one-shot) |\n| `MaxTokens` | Text | response token ceiling (client may only lower it) |\n| `AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes` | Text / edit | input image rules |\n| `MaxImages` | Image | cap of `N` |\n| `AllowEdit` | Image | `editImage` allowed |\n| `AllowReferenceImage` | Video | `ReferenceImage` allowed |\n| `MaxPromptChars` | all | prompt length limit incl. history (0 = none) |\n| `LockBehavior` | all | client overrides (size, voice, count…) are ignored |\n| `ImageSize`, `VideoSize`, `VideoSeconds`, `Voice`, `AudioFormat`, `MusicFormat`, `ThreeDOperation`, `ArtStyle`, `ThreeDFormat` | per modality | the feature's defaults, for UI |\n\n`ThreeDOperation` is `\"text-to-3d\"` (default) or `\"image-to-3d\"` (send\n`ThreeD.Image`).\n\n## Limits and defaults\n\n**There are no platform caps** (owner decision, 13.09.2026): a publisher may set\nany value the chosen **model** supports. The only ceiling is the model itself —\ne.g. `MaxTokens` above the registry model's `MaxOutputTokens` is refused when the\nconfig is saved. What an empty field means:\n\n| What | Default when the feature leaves it empty |\n| ------------------------ | ----------------------------------------------- |\n| Text response tokens | 1024 (any value up to the model's output limit) |\n| Input images per request | 1 |\n| One input image | 4 MB (4194304 bytes) |\n| Input image types | png, jpeg, webp — checked by file signature |\n| Images per image request | 1 |\n\nRead the effective values from `AIPublicFeature` (`getDefinitions()`), never\nhard-code them in the game. A text generation has no time ceiling on the\nserver — it may run for hours while the model keeps writing (only 3 minutes of\nsilence counts as a broken stream), so long answers are fine; keep\n`waitForGeneration`'s `timeoutMs` in line with how long the player will wait. The publisher's credits are reserved for the upper bound (e.g.\n`MaxTokens`) before the call and the difference is returned after it — the\nplayer is not affected by that.\n\n## Server order of checks\n\nFeature (`cfg.AI`) → idempotency → the feature's registry model (enabled,\nmodality, image input, key, price) → gate, schedule, limits, prompt checks,\ninput images → cost estimate → the publisher's plan → the title's caps →\npublisher credit hold → player resources + counters (one atomic operation) →\nthe job is queued (video / 3D: submitted to the provider) → the generate call\nanswers `pending` / `running`. Then the server's worker: provider → assets\nstored → money finalized. A refusal before the player resources step charges\nnothing and comes back as `Success: false` with `Error = \"AI_CODE: message\"`.\nAnything failing **after** a charge returns everything the player paid.\n\nPublisher-side problems (no credits, plan without InApp AI, model without a\nprice, cap reached) are deliberately reported to the player as\n`AI_UNAVAILABLE` only.\n\n## Charges, refunds, idempotency\n\n- `Charge` is the `ResourceOperation` taken from the player (its `Consume`).\n The SDK applies it to the cache **once**, from the generate response, and\n never from polls or history (they carry the same `Charge`).\n- `PlayerRefunded: true` — the generation failed after charging and the\n server returned the player's resources. The view still shows the original\n `Charge`; the SDK never applies it, and if it applied it earlier in this\n session (an async job is charged at submit) it re-reads the inventory\n (`client.user.getUserInventory()`) before emitting the events. A `failed`\n generation with `PlayerRefunded: false` keeps its charge.\n- `RelatedEntityID` is the idempotency key, scoped by the server to title and\n player (`iaai_{title}_{user}_{key}`). The server keeps only `[A-Za-z0-9_-]`\n and the first 128 characters, so the SDK sends a plain UUID and rejects a\n caller key outside `^[A-Za-z0-9_-]{1,128}$` (`reason: \"client\"`) —\n otherwise two different keys could silently become one generation. A repeat\n returns the same generation and charges nothing.\n\n## Jobs\n\nEvery generate call returns a job: `pending` (text, image, edit, speech, music —\nqueued on the server and run by its worker within seconds) or `running`\n(video, 3D — submitted to the provider). Wait with `waitForGeneration`, or\npoll the modality's own action (`getText` / `getImage` / `getAudio` /\n`getMusic` / `getVideo` / `getThreeD`). The publisher's credits are reserved\nat submit; a video / 3D job is finalized lazily — on the player's poll, on a\nhistory read, or at the start of another generation of the title. A job nobody\npolls still resolves, but the client only learns the outcome by polling or\nreading history.\n\nThe server never repeats a provider call after a timeout (the provider would\nbill it twice). A generation that fails returns everything to the player; the\npublisher pays what the provider actually charged for it.\n\n## What the publisher configures\n\n`cfg.AI` is a **server-only** section of the title config (it holds prompts\nand models): it never reaches the client config or the CDN. Per feature the\npublisher sets `Enabled`, `Modality`, `Model` (a logical id from the\nplatform's model registry), `Behavior` (system instructions and the\nmodality defaults above), `Safety` (`MaxPromptChars`, `BlockedTerms`,\n`LockBehavior`), `Gate` (audience), `Schedule`, `Limits`, `PriceOptions`,\n`Tags` — in the dashboard or via the backend title-config MCP (`save_ai`).\nWhen a game needs a feature that doesn't exist, tell the publisher what to\nconfigure; don't work around it on the client.\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-module-contract",
|
|
3
|
-
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
-
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID). A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`).\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
3
|
+
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
+
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID). A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`).\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "voxelcraft-worlds",
|
|
3
|
+
"description": "Build VoxelCraft worlds from a recipe (WorldSpec): from up to 4 reference pictures and a text description, with InApp AI (client.ai, title feature \"voxel-world\") or without it, or written by hand / by an agent. The AI can recreate WHATEVER the pictures show — build objects out of coloured blocks (a plane, a car, a ship, a statue: models made of boxes, cylinders, cones, spheres, lines, with mirror symmetry), rebuild landscapes (a hand-drawn terrain sketch, rivers, biome zones) and combine them into scenes. Covers the WorldSpec JSON format, setting a project's start world (src/worlds/startWorld.ts), the \"Create world\" screen and \"My worlds\" autosave, configuring the \"voxel-world\" AI feature for a title (system prompt, vision model, images, limits, price), and the agent actions generateWorld / loadWorldSpec. Use this whenever the user works on the voxelcraft module and wants a world from a picture, an object from a photo built in blocks, a landscape recreated, a generated / custom / themed voxel map, AI world generation, a start world for their game, or touches WorldSpec, SpecGenerator, VoxelModel, rasterizeModel, parseWorldSpec, buildSpecWithoutAI, CreateWorldUI, createClientWorldAI or VOXEL_WORLD_SYSTEM_PROMPT — even if they don't name the module. Also covers sharing worlds through the Workshop (content type \"voxelcraft.world\").",
|
|
4
|
+
"content": "---\nname: voxelcraft-worlds\ndescription: >-\n Build VoxelCraft worlds from a recipe (WorldSpec): from up to 4 reference\n pictures and a text description, with InApp AI (client.ai, title feature\n \"voxel-world\") or without it, or written by hand / by an agent. The AI can\n recreate WHATEVER the pictures show — build objects out of coloured blocks\n (a plane, a car, a ship, a statue: models made of boxes, cylinders, cones,\n spheres, lines, with mirror symmetry), rebuild landscapes (a hand-drawn\n terrain sketch, rivers, biome zones) and combine them into scenes. Covers the\n WorldSpec JSON format, setting a project's start world\n (src/worlds/startWorld.ts), the \"Create world\" screen and \"My worlds\"\n autosave, configuring the \"voxel-world\" AI feature for a title (system\n prompt, vision model, images, limits, price), and the agent actions\n generateWorld / loadWorldSpec. Use this whenever the user works on the\n voxelcraft module and wants a world from a picture, an object from a photo\n built in blocks, a landscape recreated, a generated / custom / themed voxel\n map, AI world generation, a start world for their game, or touches\n WorldSpec, SpecGenerator, VoxelModel, rasterizeModel, parseWorldSpec,\n buildSpecWithoutAI, CreateWorldUI, createClientWorldAI or\n VOXEL_WORLD_SYSTEM_PROMPT — even if they don't name the module. Also covers\n sharing worlds through the Workshop (content type \"voxelcraft.world\").\n---\n\n# VoxelCraft worlds (WorldSpec)\n\nA VoxelCraft world is built by a **deterministic generator** (`SpecGenerator`)\nfrom a compact JSON **recipe** — the WorldSpec. The same recipe always gives the\nsame world, block for block. Nothing writes the world's blocks directly;\neverything that makes a world writes a recipe:\n\n| Path | Where | What it can do |\n| ------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |\n| **AI** — pictures + description | `createClientWorldAI(client)` → `client.ai.generateText(\"voxel-world\", …)` | anything the pictures show: objects built from blocks, landscapes, scenes |\n| **Without AI** | `buildSpecWithoutAI` — first picture read in the browser (`ImageAnalyzer`), text by keywords (ru/en) | terrain from a top-down map, style from a mood picture, prefab structures by keyword — **no objects from pictures** |\n| **By hand / by an agent** | write the JSON yourself | everything the format allows |\n\nAll paths meet in `parseWorldSpec()` — the only door into the game. It fills\ndefaults, clamps numbers, drops unknown structures, shapes and blocks. A recipe\nnever crashes the generator, wherever it came from.\n\n## The WorldSpec format\n\nWorld coordinates `x`, `z`, `x2`, `z2`, `radius`, river points are **fractions\nof the world (0..1)**: `x=0` west, `x=1` east, `z=0` north, `z=1` south. Every\nfield is optional.\n\n```jsonc\n{\n \"version\": 1,\n \"name\": \"Frost Keep\", // <= 60 chars\n \"description\": \"…\",\n \"seed\": 2026, // number or string; omitted = hash of name\n \"size\": { \"mode\": \"island\", \"widthChunks\": 16, \"depthChunks\": 16 },\n // mode: island | bounded (land to the edges, sea outside) | infinite\n // 4..48 chunks per side (16 blocks each); default island 16×16 = 256×256 blocks\n \"terrain\": {\n \"shape\": \"mountains\", // island | archipelago | continent | valley | mountains | plateau | flat\n \"baseHeight\": 30, // 6..48 — average land height (the world is 64 blocks high)\n \"amplitude\": 20,\n \"roughness\": 0.6,\n \"waterLevel\": 25,\n \"sketch\": { \"rows\": [\"0012345\", \"…\"] },\n // optional TOP-DOWN elevation map drawn by the AI: 4..32 rows (north→south) of 4..32 digits\n // (west→east): 0 deep water, 1 shallow, 2 shore, 3 lowland, 4 plain, 5 hills, 6 high hills,\n // 7 mountains, 8 high mountains, 9 peaks. Smoothed + natural detail; with a sketch the recipe\n // draws its own coastline (no island mask). A top-down PICTURE's heightmap wins over it.\n // heightmap / surfaceMap — filled by the game from a top-down picture; don't write by hand\n },\n \"zones\": [\n // <= 12; each point belongs to the nearest zone (weighted by radius)\n { \"x\": 0.5, \"z\": 0.5, \"radius\": 0.4, \"biome\": \"snow\" },\n // biome: meadow | forest | birch_forest | taiga | snow | desert | badlands | beach |\n // swamp | rocky | savanna | jungle\n // overrides: surface, subsurface (any solid block — even wool), trees 0..1,\n // treeType oak|birch|spruce|palm|dead|none, grass 0..1, flowers 0..1, height -20..20\n ],\n \"structures\": [\n // <= 40\n { \"type\": \"castle\", \"x\": 0.5, \"z\": 0.5, \"size\": \"large\", \"rotation\": 180 },\n // house | tower | castle | village | wall | road | bridge | ruins | pond | rock |\n // tree_cluster | pyramid | well | farm | model | river\n // size small|medium|large; rotation 0|90|180|270 (buildings: door north|east|south|west)\n { \"type\": \"road\", \"x\": 0.25, \"z\": 0.72, \"x2\": 0.5, \"z2\": 0.5 }, // wall/road/bridge: x2,z2\n {\n \"type\": \"river\",\n \"path\": [\n [0.8, 0.2],\n [0.5, 0.5],\n [0.1, 0.95],\n ],\n }, // 2..16 points, source → mouth\n {\n \"type\": \"model\",\n \"model\": \"airplane\",\n \"x\": 0.5,\n \"z\": 0.55,\n \"rotation\": 90,\n \"elevation\": 0,\n },\n ],\n \"models\": { \"airplane\": {/* see below */} }, // <= 4 models, each can be placed many times\n \"npcs\": [\n { \"name\": \"Björn\", \"role\": \"smith\", \"x\": 0.26, \"z\": 0.7, \"persona\": \"…\" },\n ],\n \"palette\": {\n \"wall\": \"planks\",\n \"roof\": \"dark_planks\",\n \"floor\": \"planks\",\n \"path\": \"dirt_path\",\n \"accent\": \"stone_bricks\",\n },\n \"ambience\": { \"timeOfDay\": \"dawn\" }, // dawn | day | sunset | night\n}\n```\n\n### Models — objects built from blocks\n\nThe AI builds an object (a plane, a car, a ship, a statue, a creature) from\n**shapes**, not block by block — a language model reasons well about\n\"fuselage = cylinder, wings = flat boxes, symmetric\", and a shape list stays\nshort. `rasterizeModel()` turns it into blocks.\n\n```jsonc\n\"airplane\": {\n \"size\": [40, 12, 33], // [length x, height y, width z], <= 64 × 40 × 64\n // model coords: x from the FRONT (x=0, the nose) back, y UP (y=0 on the ground), z left→right;\n // the middle plane is z = (width - 1) / 2. Later shapes overwrite earlier; \"air\" carves.\n \"parts\": [ // <= 200\n { \"shape\": \"cylinder\", \"axis\": \"x\", \"center\": [4, 4, 16], \"radius\": 2.5, \"length\": 30, \"block\": \"white_wool\" },\n // center = middle of the START cap; runs `length` blocks along +axis (negative = −axis)\n { \"shape\": \"cone\", \"axis\": \"x\", \"center\": [3, 4, 16], \"radius\": 2.5, \"radius2\": 0.6, \"length\": -4, \"block\": \"white_wool\" },\n { \"shape\": \"box\", \"from\": [14, 3, 1], \"to\": [19, 3, 13], \"block\": \"white_wool\", \"mirror\": \"z\" },\n // \"mirror\": \"x\" | \"z\" also draws the mirror copy across the middle — wings, wheels, lights\n { \"shape\": \"sphere\", \"center\": [x, y, z], \"radii\": [rx, ry, rz], \"block\": \"…\", \"hollow\": true },\n { \"shape\": \"line\", \"from\": [x, y, z], \"to\": [x, y, z], \"radius\": 0, \"block\": \"…\" },\n { \"shape\": \"box\", \"from\": [33, 9, 16], \"to\": [34, 11, 16], \"block\": \"air\" }\n ],\n \"voxels\": [[9, 5, 14, \"light_blue_wool\"]] // <= 400 single blocks for details\n}\n```\n\nPlaced by a `model` structure: centred at `x, z`, turned by `rotation`, its\nbottom on the ground under the centre (on water — on the surface), `elevation`\nlifts it (a plane in flight). The footprint is cleared (trees and bumps don't\ngrow through it). Model blocks written with Minecraft names are forgiven:\n`red_concrete`, `cyan_terracotta`, `minecraft:lime_wool` → our wool of that colour;\n`*_stained_glass` → glass. Anything unknown drops that shape.\n\n### Blocks\n\ngrass, dirt, stone, cobblestone, mossy_cobblestone, sand, red_sand, gravel,\nclay, snow, ice, sandstone, terracotta, stone_bricks, bricks, planks,\ndark_planks, oak_log, spruce_log, birch_log, glass, dirt_path, leaves,\nspruce_leaves, birch_leaves, and wool in 12 colours (white, red, orange,\nyellow, green, blue, light_blue, purple, pink, brown, gray, black — `red_wool`…).\n\nGood to know: a **village** already has houses facing a well, paths and (large)\na farm; a **bridge** needs water under it (`valley` shape, a river, a pond);\na **river** always flows downhill from its first point and cuts its bed through\nhills; keep structures inside `0.2..0.8` on an island; the player spawns on dry\nland nearest the centre, away from structures.\n\n## The project's start world\n\n`src/worlds/startWorld.ts` (in a project: `src/modules/voxelcraft/src/worlds/startWorld.ts`):\n\n```ts\nexport const START_WORLD: unknown = { name: \"Frost Keep\", terrain: { shape: \"mountains\" }, … };\n```\n\n`null` (default) = the classic seed-1337 noise world. A player with a saved\nworld continues it (\"My worlds\"). Easiest way to get a recipe: in the game,\n**Create world → Download recipe**, then paste the JSON as an object.\n\n## In the game\n\n- **Create world** (pause overlay): up to **4 pictures** (drop, click, Ctrl+V —\n e.g. one car from several angles), description, size (island 256 / large 512\n / infinite / custom), how to read the pictures:\n - **Образец для ИИ** (default) — the AI decides from the description what the\n pictures are (an object to build, a landscape to recreate, a style);\n - **Карта сверху** — the first picture is a top-down map: blue = water, land\n rises from the shore, pixel colour → nearest block (works without AI too);\n - **Настроение** — only climate, colours, materials.\n Then with or without AI, a **mini-map preview** (red dot = spawn), play /\n download / load recipe.\n- **My worlds**: every world autosaves to IndexedDB (every 30 s of play, on\n pause, before switching worlds); the last one reopens on the next launch.\n\n## AI: the \"voxel-world\" feature of the title\n\nThe AI option appears only when the title has a Text feature with key\n`voxel-world` (`client.ai.getDefinitions()`). Configure it in the dashboard\n(InApp AI page) or via the title-config MCP `save_ai`:\n\n- `Modality: \"Text\"` (every generation is a queued job the game polls — a world\n with models is a long answer, minutes; there is no Async switch any more).\n- `Model`: from `GetInAppAIModels`, with **image** among its input modalities\n (otherwise image requests are refused before any charge). Model quality =\n object quality: a strong vision model builds far better planes and cars.\n- `Behavior.SystemInstructions` = **`VOXEL_WORLD_SYSTEM_PROMPT`** from\n `src/ai/worldSpecPrompt.ts`, verbatim (a test keeps it in sync with the\n format). `HistoryMessages: 0`. **`MaxTokens` ≈ 48000** — models are the long part, and a\n reasoning model (GLM 5.3 Flash) spends part of the limit on thinking: at 16000 the\n first live run was cut off, the second took ~18000 output tokens (~1.6 credits).\n- `AllowImages: true`, **`MaxInputImages: 4`** or more — the game sends as many\n JPEGs (≤ 1568 px) as the feature allows; there is no platform cap, only what\n the model accepts.\n- `Safety.LockBehavior: true`, `MaxPromptChars: 1000` (the game trims the description).\n- `Limits` (`DailyCap`, `CooldownSeconds`) and `PriceOptions`.\n\n⚠ No JSON mode in the engine, and an answer cut off by `MaxTokens` comes back\n`completed` **and is charged**. The game detects a cut answer and builds the\nworld without AI instead — keep `MaxTokens` generous.\n\n`createClientWorldAI(client)` (`src/ai/worldAi.ts`): status from definitions,\none `relatedEntityID` per click (a dropped connection is retried with the same\nkey — no double charge), polling of the job, a job abandoned by closing\nthe screen is resumed next time, refusals mapped to player-facing messages, the\nplayer's chosen size enforced over the model's.\n\n## Sharing worlds in the Workshop\n\nVoxelCraft registers the content type **`voxelcraft.world`** (`src/workshop.ts`,\n`ctx.content.registerType` in `module.ts`). With the `workshop` module installed, players publish\nworlds from \"My worlds\" and open other players' worlds; the Workshop switches to the `voxelcraft`\nmode after opening.\n\n- `capture` publishes the world's **SaveData JSON** (role `main`, `application/json`) — the source\n plus the player's diffs, not the blocks — and a **webp preview** for recipe worlds (noise worlds\n have none). Metadata: `kind`, `seed`.\n- `open` parses the save, opens it in the running game (`openSharedWorld`) or stores it as a new\n local world.\n- Title config (dashboard → LiveOps → Workshop → Content types, or the title-config MCP):\n `ContentTypes[\"voxelcraft.world\"] = { DisplayName, Files: { main: { AllowedMimeTypes:\n[\"application/json\"], MaxBytes: … } } }`. Don't set `ThumbnailRequired` — noise worlds can't\n capture a preview. How to charge / gate access: **workshop-system**.\n\n## Agent debug surface\n\n`state().world`: `id`, `name`, `source` (`noise` | `spec`), `seed`, `size`,\n`shape`, `biomes`, `structures`, `fromPicture`, `spawnHint`; `createWorldOpen`.\nActions: `generateWorld { text, size?, seed? }` (without AI),\n`loadWorldSpec { spec }` (from a recipe object; returns `warnings`). The screen\nitself is plain DOM — read it and click it like a player.\n\n## Don't break\n\n- **Generators are part of the save format.** A save is `source + diffs`; the\n world is regenerated and the diffs land on it. Changing the output of\n `TerrainGenerator`, `SpecGenerator`, the structure builders or\n `rasterizeModel` corrupts every saved world — and every world **published in\n the Workshop**, which is the same SaveData. Both generators have golden\n fingerprint tests — a red one means \"you broke saves\", not \"update the numbers\".\n- **Block ids are append-only** (`registry/Blocks.ts`); new tiles go to the END\n of `TILE_ORDER` (`gfx/TextureAtlas.ts`).\n- The module takes **only types** from `@idosgames/core`; the client comes from\n the host (`ctx.client`).\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "workshop-system",
|
|
3
|
+
"description": "Let players share what they make — maps, levels, worlds, skins, 3D models, any file — through the Workshop of the iDosGames TypeScript SDK (@idosgames/core client.workshop, WorkshopService): configure content types for a title, publish with files and a thumbnail, set access (free, a price in in-game resources, or \"hold these resources to unlock\" — while held or once), browse the catalog with filters and publisher collections, acquire, download and open content, likes, favorites, following authors, reports, official content. Also covers plugging a game into the ready-made `workshop` module via ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever the user wants user-generated content, a level/map sharing screen, selling maps or skins between players, unlocking content for holders of an item, a creator catalog, or touches client.workshop, WorkshopService, publish, acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions, ContentTypes, ctx.content or the workshop module — even if they don't name the module.",
|
|
4
|
+
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `like`/`unlike`, `favorite`/`unfavorite`, `getMyFavorites`, `follow`/`unfollow`,\n `getCreatorProfile`, `getCollection`, `report`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired | reacted`.\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module lists your local items, publishes them, and after `open` switches to `modeId`. A type\nwith no handler is still browsable and acquirable — it just can't be published or opened from the\ngame. Contract details: **idosgames-module-contract**.\n\n## Don't\n\n- Don't gate access on the client — show `getContent`'s option availability and let `acquire` decide.\n- Don't write a \"license\" of your own for `WhileHeld` content: access must follow the player's\n inventory.\n- Don't cache signed URLs or put private files in the public config.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|