@idosgames/mcp 0.1.11 → 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/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +18 -6
- package/registry/index.json +130 -26
- package/registry/modules/board-game.json +31 -10
- package/registry/modules/game-hud.json +99 -0
- package/registry/modules/idle-rpg.json +35 -10
- package/registry/modules/voxelcraft.json +139 -34
- package/registry/skills/ai-generation-system.json +11 -0
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/craft-system.json +2 -2
- 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 +6 -0
- package/registry/skills/item-system.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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "character-system",
|
|
3
3
|
"description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID, options?)` | Buy/unlock a locked character (charges the selected `Unlock.PriceOptions` option). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.PriceOptions and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\n// Third argument picks the way to pay and carries a store receipt when the option needs one.\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has `Unlock.PriceOptions` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no options and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\nWhen the character has several ways to pay, render them with\n`client.checkout.availableOptions(def.Unlock.PriceOptions)` and pass the chosen one:\n\n```ts\nawait client.character.unlockCharacter(\"Knight\", {\n selectedOptionID: option.OptionID,\n // required only when this option is paid in a store (a `Purchase` entry in its Cost)\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
|
|
4
|
+
"content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID, options?)` | Buy/unlock a locked character (charges the selected `Unlock.PriceOptions` option). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.PriceOptions and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\n// Third argument picks the way to pay and carries a store receipt when the option needs one.\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has `Unlock.PriceOptions` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no options and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\nWhen the character has several ways to pay, render them with\n`client.checkout.availableOptions(def.Unlock.PriceOptions)` and pass the chosen one:\n\n```ts\nawait client.character.unlockCharacter(\"Knight\", {\n selectedOptionID: option.OptionID,\n // required only when this option is paid in a store (a `Purchase` entry in its Cost)\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Skins (alternative looks)\n\nA skin is an **item**. A character's `Skins.Definitions` names which catalog item\n_is_ each skin (non-stackable, no expiration). Owning a skin = owning a copy of\nthat item — so store offers, lootboxes, season tiers, quests and rewards grant\nskins with no extra wiring. Wearing binds that copy to the reserved equipment\nkey `SKIN_SLOT` (`\"@skin\"`); the worn skin is a regular `Equipment[SKIN_SLOT]`\nentry, and its item `Stats` (if any) count toward `Power` exactly like gear.\n\n```ts\nimport { SKIN_SLOT, SKIN_ALREADY_OWNED, isReservedSlot } from \"@idosgames/core\";\n\n// Buy from the character screen (only skins with Sale.PriceOptions are sold here)\nconst buy = await client.character.unlockSkin(\"Knight\", \"golden\", {\n autoEquip: true,\n});\nif (!buy.ok) {\n if (buy.error === SKIN_ALREADY_OWNED) hideBuyButton();\n else showError(buy.error);\n} else if (!buy.data.Equipped) {\n toast(buy.data.EquipError); // bought — but the wear requirements aren't met yet\n}\n\nawait client.character.equipSkin(\"Knight\", \"golden\"); // wear an owned skin\nawait client.character.equipSkin(\"Knight\", \"base\"); // the base look = no skin\nawait client.character.unequipSkin(\"Knight\"); // same, explicitly (idempotent)\n\n// Render: the worn skin (or none), gear slots without the reserved key\nconst worn = knight.Equipment?.[SKIN_SLOT]; // knight: the cached CharacterModel\nconst gear = Object.entries(knight.Equipment ?? {}).filter(\n ([slot]) => !isReservedSlot(slot),\n);\n```\n\n- **Which skin is worn** — match `worn.ItemID` against `Skins.Definitions[*].Item.ItemID`\n (the SkinID is not stored in state, so renaming a skin in config breaks nothing).\n- **Owned?** — `InventoryV2.Items[skin.Item.ItemID].TotalAmount > 0`.\n- **Sale.Schedule / Sale.Gate restrict buying only.** A skin the player owns can\n always be worn; `Requirements` (rank, stats) gate wearing.\n- Empty `Sale.PriceOptions` means **not sold directly** (granted by other systems\n only) — not \"free\", unlike most prices.\n- **What to show in the skin shop** — `client.character.getCharacterSkins(\"Knight\")`\n returns, per skin, `Owned` / `IsWorn` / `OnSale` / `GateOpen` /\n `MeetsRequirements` / `Purchasable` and the prices for this platform. Drive the\n Buy button from `Purchasable`: audience gates and sale windows can't be\n evaluated on the client.\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "craft-system",
|
|
3
3
|
"description": "Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.craft (CraftService): load craft recipe definitions and execute a craft that burns input item instances (trade-up by rarity or trade-up by collection) to produce a rolled output item. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage UIs, or otherwise touches client.craft, CraftService, CraftDefinitions, CraftDefinition, or CraftResponse — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\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 craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?)` | Burn `inputItemIDs`, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are item _instance_ ids, not catalog/definition ids.** The\n server checks each instance's underlying `ItemID` against the recipe's\n allowed-input set (by `InputRarityID`, and by `CollectionID` too for\n `TradeUpCollection`) and that the player actually holds enough of that item\n in total (equipped instances don't count — see Gotchas).\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<OptionID, { OptionID, Name, Cost, AllowedPlatforms }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the instance ids actually consumed for this iteration\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.Cost.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Craft\" burns items twice. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
|
|
4
|
+
"content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\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 craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?, inputInstanceIDs?)` | Burn the inputs, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are catalog `ItemID`s, not instance ids.** The server checks\n each id against the recipe's allowed-input set (by `InputRarityID`, and by\n `CollectionID` too for `TradeUpCollection`) and that the player holds enough\n of that item in total (equipped instances don't count — see Gotchas). WHICH\n copies burn is decided by the recipe's `InputSelection` — see \"Output level\n from input levels\" below; to name specific instances use `inputInstanceIDs`.\n- **`inputInstanceIDs`** — only for a recipe with `InputSelection:\n\"ClientSelected\"`. One instance id per **unstackable** input of the\n _expanded_ list (template × `count`), in the same order; stackable inputs\n take no slot. A bundle's id may repeat once per unit it holds. Sending them to\n any other recipe is rejected (`\"InputInstanceIDs are accepted only by a craft\nwith InputSelection = ClientSelected.\"`); omitting them on a ClientSelected\n recipe falls back to the server picking non-upgraded copies only.\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<OptionID, { OptionID, Name, Cost, AllowedPlatforms }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the catalog ItemIDs consumed in this iteration (your template)\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Output level from input levels\n\nA recipe can make the output inherit the level of its inputs. Three config\nfields, all defaulting to the legacy behaviour:\n\n| Field | Values | Absent = |\n| --------------------- | ------------------------------------------------------------------------------ | --------------------------------- |\n| `OutputLevelMode` | `None` / `Min` / `Average` (rounded down) / `Max` — per craft | `None` (output is level 1) |\n| `InputSelection` | `ProtectLeveled` / `ClientSelected` / `LowestLevelFirst` / `HighestLevelFirst` | `ProtectLeveled` (only Level ≤ 1) |\n| `OutputLevelOverflow` | `Clamp` / `Reject` — when the level exceeds the output's `Upgrade.MaxLevel` | `Clamp` |\n\nStackable inputs and non-upgraded copies count as level 1; an output that is\nstackable or has no `Upgrade` is capped at level 1. With `ProtectLeveled`,\n`OutputLevelMode` has no effect — upgraded copies never enter the craft.\nEquipped and expired instances are never burned in any mode.\n\n```ts\n// Recipe: { OutputLevelMode: \"Average\", InputSelection: \"ClientSelected\", RequiredItemCount: 2 }\nconst res = await client.craft.craft(\n \"merge-swords\",\n [\"sword\", \"sword\"], // catalog ItemIDs — the template\n 1,\n undefined,\n [\"inst-lv4\", \"inst-lv7\"], // which copies to burn\n);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.OutputLevel; // 5 — (4 + 7) / 2, rounded down\nres.data.Results?.[0]?.BurnedInstances; // [{ ItemInstanceID, ItemID, Level, Units }, …]\n```\n\n`Reject` is checked against the **lowest** `MaxLevel` of all possible outputs,\nbefore the roll, so the same request never passes on one try and fails on\nanother. A retry with the same idempotency key after the inputs were already\nburned returns the original result (`Results` empty) instead of \"not found\".\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.Cost.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Craft\" burns items twice. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n}\n```\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## PriceOption\n\nOne payment option for a recipe — the platform-wide price shape, identical in\nevery module (see the `checkout-system` skill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string;\n Cost?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A craft can never be paid in a store.** The price is per craft and multiplied\nby `count`, while a receipt pays for exactly one SKU — there is no \"one and a half\nreceipts\" for a batch of one and a half crafts. A `Purchase` entry here is rejected\nwith `\"Craft cannot be paid in a store.\"`\n\n`Cost` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option with\n an empty cost is used, no input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first option\n available on the caller's platform**, ordered by `OptionID`. The order is\n explicit (not dictionary order) so the default is deterministic — but it is\n still \"first\", not \"cheapest\".\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `Purchase` are rejected outright → `\"Craft cannot be\npaid in a store.\"` (see the PriceOption section above)\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the instance ids consumed in this specific iteration\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n}\n```\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
|
|
8
|
+
"content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n\n // Output level from input levels — all absent = legacy behaviour.\n OutputLevelMode?: \"None\" | \"Min\" | \"Average\" | \"Max\"; // per craft; Average rounds down; absent = None\n InputSelection?:\n | \"ProtectLeveled\"\n | \"ClientSelected\"\n | \"LowestLevelFirst\"\n | \"HighestLevelFirst\"; // absent = ProtectLeveled\n OutputLevelOverflow?: \"Clamp\" | \"Reject\"; // vs the output's Upgrade.MaxLevel; absent = Clamp\n}\n```\n\n- `ProtectLeveled` burns only non-upgraded copies (oldest first) — the only\n behaviour that existed before; with it `OutputLevelMode` has no effect.\n- `ClientSelected` burns the instances named in `InputInstanceIDs` (any\n level); without them it behaves like `ProtectLeveled`.\n- `LowestLevelFirst` / `HighestLevelFirst` — the server picks by level\n (then oldest first). Equipped and expired instances are never picked.\n- The output cap is `Upgrade.MaxLevel`; a stackable output or one without\n `Upgrade` is capped at 1. `Reject` compares against the lowest cap in the\n output pool, before the roll.\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## PriceOption\n\nOne payment option for a recipe — the platform-wide price shape, identical in\nevery module (see the `checkout-system` skill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string;\n Cost?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A craft can never be paid in a store.** The price is per craft and multiplied\nby `count`, while a receipt pays for exactly one SKU — there is no \"one and a half\nreceipts\" for a batch of one and a half crafts. A `Purchase` entry here is rejected\nwith `\"Craft cannot be paid in a store.\"`\n\n`Cost` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option with\n an empty cost is used, no input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first option\n available on the caller's platform**, ordered by `OptionID`. The order is\n explicit (not dictionary order) so the default is deterministic — but it is\n still \"first\", not \"cheapest\".\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `Purchase` are rejected outright → `\"Craft cannot be\npaid in a store.\"` (see the PriceOption section above)\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the catalog ItemIDs consumed in this iteration (the template)\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n OutputLevel?: number; // only when OutputLevelMode !== \"None\"\n BurnedInstances?: { ItemInstanceID; ItemID; Level; Units }[]; // only when inputs were pinned to instances\n}\n```\n\nAn output above level 1 is granted as its own instance (a bundle is always\nlevel 1), and pinned inputs are burned by instance id — both are still listed in\n`Resources` (`Consume` / `Grant`) and in the `Inventory` delta, so\n`applyResourcesWithDelta` keeps the cache exact. On an idempotent replay\n`Resources` is the stored operation, which does not carry those two kinds of\nlines.\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-compose-modules",
|
|
3
|
-
"description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the cross-module
|
|
4
|
-
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n cross-module
|
|
3
|
+
"description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the shared HUD (game-hud, sharedUi roles, shouldDraw), 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 Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n shared HUD (game-hud, sharedUi roles, shouldDraw), typed cross-module events (defineTopic/shape),\n or host-level shared state work. Builds on idosgames-getting-started\n (scaffolding) and idosgames-module-contract (a single module).\n---\n\n# Composing modules into one game\n\nThe whole point of the architecture: a developer plugs in several modules and merges them. The host\nhandles coexistence — you just register the modules and (optionally) wire shared state.\n\n## Register several modules\n\n```ts\n// src/modules.ts\nexport const modules: Module[] = [\n boardGameModule, // Three tycoon\n idleRpgModule, // Phaser idle\n];\n```\n\nEach game module registers a route → the host renders a **nav-bar** (`🎲 Board | ⚔️ Idle RPG`) and\n**mode-switches**: only the active mode's scene is mounted and ticking; the rest are suspended\n(their RAF stops). This is why two different engines (Three + Phaser) can live in one project — they\nnever render at the same time. Modules of the same engine family may later share a renderer\n(composition), but the module code doesn't change either way.\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 — same `client.currency`, same\n 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. The client and the event bus are the seams between them —\n which is what keeps a module reusable in another game. Code several of THIS game's modules need\n (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a module\n (see idosgames-project-structure).\n\n## Shared chrome: roles + game-hud\n\nBalances, the crypto wallet, the status line and \"Log out\" / player ID are **shared-UI roles**\n(`currency-bar | wallet | status | account`). A module that draws one for EVERY mode declares it\nstatically on the module object:\n\n```ts\nexport const gameHudModule = defineModule({\n id: \"game-hud\",\n meta: { name: \"Game HUD\", type: \"app\", engine: \"dom\" },\n sharedUi: { provides: [\"currency-bar\", \"wallet\", \"status\", \"account\"] },\n setup(ctx) {\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: GameHud,\n });\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider in\n`src/modules.ts` wins, a conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`.\nA template draws its own copy only while nobody took the role. So:\n\n- **Mixing two or more templates → install `game-hud`** (catalog, `type: feature`). Do NOT edit the\n templates to remove their wallets/balances/status: they hide those themselves, and an edit would\n mark them customized (no more catalog updates). The platform's AI editor installs game-hud\n automatically when a project starts from two or more templates.\n- A template installed alone stays a complete game — no owner, so it draws everything.\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- When a module takes `account`, the host stops drawing its own bottom-left Log out / ID row.\n- A custom HUD replaces game-hud the same way: declare the roles, draw them.\n\n**Layout is the host's job.** It measures the `hud` slot and the nav and moves the `overlay` and\n`sidebar` layers between them, so a template's overlay never slides under the HUD or the nav — no\ntemplate changes. Anything drawn outside those layers (a scene's own DOM HUD) uses the CSS\nvariables on the host root: `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)` (voxelcraft's hotbar\ndoes this) and `--idos-safe-top`. Both are 0 with no HUD and a single mode. HUD panels are wrapped\nin `display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let\nclicks through 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 merge\n\n`host-starter` + `board-game` + `idle-rpg` + `game-hud`:\n\n- nav-bar switches modes; one engine scene mounted at a time;\n- game-hud is pinned across all modes: one balance bar, one wallet, one status line, the account\n chip — the templates' own copies are hidden, and both modes' status messages land in the HUD;\n- board's footer and idle's dock sit between the HUD and the nav;\n- currency changed in one mode is immediately visible in the others; levelling a hero in Idle RPG\n 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 feature modules (board-game, idle-rpg, voxelcraft, …). 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). 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 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). 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```\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[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\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`).\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
|
|
3
|
+
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). 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 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",
|
|
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), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, 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), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, 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 (cross-module bus) · 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\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\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## 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": "idosgames-project-structure",
|
|
3
|
+
"description": "The layout standard for an iDosGames game project (host shell + composable modules) and how it grows without turning into a mess: where a new feature goes (a catalog module, an existing module, or a new module), the folder layout inside a module, module boundaries (no cross-module imports), the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated, idos.modules.lock.json and customized catalog modules, file size, and project documentation (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing src/shared/, moving code between files or folders, adding a system that spans several modules, or writing project documentation.",
|
|
4
|
+
"content": "---\nname: idosgames-project-structure\ndescription: >-\n The layout standard for an iDosGames game project (host shell + composable modules) and how it\n grows without turning into a mess: where a new feature goes (a catalog module, an existing module,\n or a new module), the folder layout inside a module, module boundaries (no cross-module imports),\n the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated,\n idos.modules.lock.json and customized catalog modules, file size, and project documentation\n (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing\n src/shared/, moving code between files or folders, adding a system that spans several modules, or\n writing project documentation.\n---\n\n# Project structure (iDosGames game projects)\n\n## The project at a glance\n\n```\nIDOS.md project guide every agent reads first — short, evergreen\nAGENTS.md, CLAUDE.md pointers to IDOS.md for external tools (Cursor, Codex, Claude Code…)\nidos.modules.lock.json platform-owned: catalog modules, their versions and file fingerprints\ndocs/feature-history/ one file per game system + README.md index\npackage.json · vite.config.ts · tsconfig.json · index.html\nsrc/\n main.tsx the host: ONE SDK client + mountHost(...)\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts · env.ts title / build-key resolution\n LoginScreen.tsx the login screen — restyle freely\n shared/ optional: code two or more of THIS game's modules need\n ui/ types/ utils/\n modules/\n <id>/ one folder per module: catalog templates, catalog features, the game's own\n```\n\nEverything the game does lives in a module. Ready-made catalog modules and the game's own modules\nshare the one `src/modules/` folder — where a module came from is recorded in\n`idos.modules.lock.json`, not in its path, because a catalog template becomes the creator's code the\nmoment they start changing it. `src/shared/` is the one place for code several of the game's own\nmodules use; there is no project-level `utils/` or `components/` besides it.\n\n## Where does a new feature go?\n\nDecide in this order:\n\n1. **The catalog already has it** → install that module (the AI editor's InstallModule, or\n `get_module` over MCP) and adapt it. Don't rebuild what exists.\n2. **It extends an existing module's gameplay** (a new tile type in the board game, a new enemy in\n the idle RPG) → change that module, inside its folder.\n3. **It is its own mode, screen or system** (a shop, a clan screen, a mini-game, a quest board) → a\n **new module** `src/modules/<feature-id>/`.\n4. **It is chrome for every mode.** Balances, the wallet, the status line, Log out / player ID are\n shared-UI roles → a module that **provides** them (`sharedUi: { provides: [...] }` in module.ts):\n install `game-hud` from the catalog, or change it; templates hide their own copies by\n themselves. Other UI every mode shows (a global menu) → a panel with `activeOnly: false` in a\n no-scene, no-route module (`type: \"app\"`, `engine: \"dom\"`). See **idosgames-compose-modules**.\n5. **It is code two or more of the game's modules need** (a shared type, the game's UI kit, a\n formatting helper) → `src/shared/` (see below).\n\nBetween 2 and 3: if it would get its own nav tab, or could be switched off on its own, it is its own\nmodule.\n\n## Inside a module\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ id, meta, setup(ctx) { … } })\n module.meta.json manifest: type, summary, provides, tags, version, author\n components/ React panels and UI pieces\n game/ engine and simulation (engine subfolders are fine: game/phaser/, game/three/)\n data/ static tables and tuning (levels, tiles, item lists)\n react/ hooks, contexts, the scene↔panel controller bridge\n```\n\n- The folder id is kebab-case `[a-z0-9-]`, at most 64 characters. The export name is derived from it\n — `daily-quests` → `dailyQuestsModule` — and the platform registers the module by that exact name.\n- Create only the folders you need; a small module can be `index.ts` + `module.ts` + one component.\n- `voxelcraft` is a ported vanilla game with its own layout — leave it as it is. New modules follow\n the layout above.\n\n### module.meta.json\n\n```json\n{\n \"id\": \"daily-quests\",\n \"type\": \"feature\",\n \"summary\": \"One-line pitch shown in the Modules dialog.\",\n \"description\": \"What it does, in a few sentences.\",\n \"provides\": [\"daily quest board\", \"streak rewards\"],\n \"tags\": [\"quests\", \"retention\"],\n \"version\": \"0.1.0\",\n \"author\": { \"name\": \"…\" },\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"daily-quests:quest-completed@1\",\n \"when\": \"a quest's reward was claimed\",\n \"payload\": { \"questId\": \"string\" }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"progress 'level a hero' quests\"\n }\n ]\n }\n}\n```\n\n`type` is `template` (a complete game to start from) or `feature` (a capability added to a game).\n`events` declares every `defineTopic(...)` the module uses — its own topics under `emits` (with the\nsame payload descriptor it passes to `shape()`), other modules' under `listens`; omit it when the\nmodule has none.\n`provides` is what an agent matches a request against — keep it accurate. Every module carries this\nfile, the game's own included: it is how a module shows up in the Modules dialog, and what lets it be\npublished to the shared catalog for other creators later.\n\n### Register it\n\n`src/modules.ts` has exactly this shape — imports plus the array, nothing else:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nimport { dailyQuestsModule } from \"./modules/daily-quests\";\n\nexport const modules: Module[] = [boardGameModule, dailyQuestsModule];\n```\n\nThe platform regenerates this file whenever modules are installed or removed; any other code in it is\nlost. Array order is mount order (and nav order).\n\n## Module boundaries\n\nA module imports only:\n\n- files inside its own folder;\n- `src/shared/` — the game's shared code;\n- npm packages (`@idosgames/*`, `react`, `three`, `phaser`, …).\n\nNever another module's files (`../board-game/…`) and never host files (`../../main`, `../../config`).\nModules are mixed into different games — a module that reaches into its neighbours breaks the moment\none of them is removed or replaced. Cooperate instead through:\n\n- **Durable shared state** (currency, inventory, characters, progress) → the ONE SDK client every\n module receives as `ctx.client`. Gold earned in one mode is spendable in another with no plumbing.\n- **Live signals** → `ctx.events` with typed topics `<module-id>:<event>@<major>`\n (`defineTopic(\"idle-rpg:character-upgraded@1\", shape({ … }))`), declared in `module.meta.json`.\n The emitter doesn't know who listens; a listener keeps its own copy of the topic. Topics shared by\n several of THIS game's own modules can live in `src/shared/events/` and be imported on both sides.\n- **Code several modules need** → `src/shared/`.\n- **Logic that must be shared and trusted** → it belongs on the server (SDK services, CloudCode),\n not in a client file.\n\n## src/shared/ — the game's shared code\n\nFor code that two or more of THIS game's own modules need.\n\n- **Created on demand.** Code only one module needs stays inside that module; move it to\n `src/shared/` when a second module needs it, not in advance.\n- **One-way dependency.** Modules import from `src/shared/`; `src/shared/` NEVER imports a module.\n Otherwise every module using the shared code silently drags another module in with it.\n- **No game state, no game logic.** Types, constants, the game's UI components and theme, pure\n helpers. Progress and data go through the SDK client, signals through `ctx.events`.\n- **Organised by purpose:** `src/shared/ui/`, `src/shared/types/`, `src/shared/utils/` — not one pile.\n- **Catalog modules never depend on it.** A module that ships in the catalog must install into any\n game, so it is fully self-contained. A game's own module that imports `src/shared/` is tied to this\n game — fine for your own game; when you publish such a module, its shared code is copied into it\n (the Modules dialog marks these modules \"uses shared code\").\n\n## Catalog modules you change\n\nA module installed from the catalog is source in your project — change it freely. At install the\nplatform records its file fingerprints in `idos.modules.lock.json`; once any file differs, the module\ncounts as **customized**, and updating it from the catalog is refused until the user confirms\noverwriting their changes in the Modules dialog. An agent never forces that overwrite. Write down\nnon-obvious customizations in `docs/feature-history/` so the next person knows why the module differs\nfrom the catalog.\n\n## Files\n\n- Keep files focused. When a change adds a new responsibility to a file past ~400 lines, move that\n part into its own file — as part of that change, not as a separate drive-by refactor.\n- `src/idos.title.ts` and `idos.modules.lock.json` are platform-owned: never edit them.\n\n## Documentation\n\n- **IDOS.md** — the always-loaded guide: what the game is (\"This game\"), layout, conventions,\n \"never do X\" constraints, lasting user preferences. Short and evergreen; change a line when a fact\n changes. The \"Installed modules\" block is maintained by the platform.\n- **docs/feature-history/<slug>.md** — one file per game system or feature: what was built, why,\n and the decisions and constraints the code does not show. Update the system's file when it\n changes, and give every file ONE line in `docs/feature-history/README.md`:\n `- [Title](slug.md) — one-line gist`.\n- Never append a feature write-up to IDOS.md. It is loaded on every run, so every paragraph there is\n paid for by all future work — that is exactly how instruction files bloat.\n- Feature-history entries are not loaded automatically: open the relevant one before changing that\n system.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|