@idosgames/mcp 0.1.45 → 0.1.47

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.
Files changed (44) hide show
  1. package/dist/cli.js +1597 -1542
  2. package/package.json +1 -1
  3. package/registry/host.json +3 -3
  4. package/registry/index.json +193 -136
  5. package/registry/modules/ai-media-app.json +77 -0
  6. package/registry/modules/ai.json +3 -3
  7. package/registry/modules/board-game.json +4 -4
  8. package/registry/modules/boosts.json +3 -3
  9. package/registry/modules/character.json +3 -3
  10. package/registry/modules/chat.json +3 -3
  11. package/registry/modules/collections.json +3 -3
  12. package/registry/modules/coop-events.json +3 -3
  13. package/registry/modules/craft.json +3 -3
  14. package/registry/modules/deals.json +3 -3
  15. package/registry/modules/events.json +6 -6
  16. package/registry/modules/friends.json +3 -3
  17. package/registry/modules/hero-run.json +14 -233
  18. package/registry/modules/idle-rpg.json +4 -4
  19. package/registry/modules/inventory.json +3 -3
  20. package/registry/modules/invite.json +3 -3
  21. package/registry/modules/kit-game.json +177 -0
  22. package/registry/modules/leaderboard.json +3 -3
  23. package/registry/modules/lootboxes.json +3 -3
  24. package/registry/modules/mailbox.json +3 -3
  25. package/registry/modules/marketplace.json +3 -3
  26. package/registry/modules/multiplayer.json +3 -3
  27. package/registry/modules/premium.json +3 -3
  28. package/registry/modules/profile.json +3 -3
  29. package/registry/modules/pvp.json +3 -3
  30. package/registry/modules/quests.json +3 -3
  31. package/registry/modules/rewards.json +3 -3
  32. package/registry/modules/season.json +6 -6
  33. package/registry/modules/store.json +3 -3
  34. package/registry/modules/tutorials.json +6 -6
  35. package/registry/modules/video-studio.json +4 -4
  36. package/registry/modules/voxelcraft.json +4 -4
  37. package/registry/modules/workshop.json +3 -3
  38. package/registry/skills/ai-generation-system.json +1 -1
  39. package/registry/skills/currency-system.json +1 -1
  40. package/registry/skills/idosgames-compose-modules.json +1 -1
  41. package/registry/skills/idosgames-getting-started.json +2 -2
  42. package/registry/skills/idosgames-module-contract.json +2 -2
  43. package/registry/video.json +1 -1
  44. package/registry/modules/mini-game.json +0 -69
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.26.0",
40
- "@idosgames/module-sdk": "0.13.3",
41
- "@idosgames/react": "0.7.0",
39
+ "@idosgames/core": "0.26.2",
40
+ "@idosgames/module-sdk": "0.13.4",
41
+ "@idosgames/react": "0.7.2",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "12cf31156c913e7275c83bbff9b555821f7eb8dff6241ceb569da20ed87de1eb",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.26.0",
40
- "@idosgames/module-sdk": "0.13.3",
41
- "@idosgames/react": "0.7.0",
39
+ "@idosgames/core": "0.26.2",
40
+ "@idosgames/module-sdk": "0.13.4",
41
+ "@idosgames/react": "0.7.2",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "158824605f43164105e68883333c347fd5110a14b1ccb43c68036db930bbfe5f",
@@ -39,9 +39,9 @@
39
39
  "version": "0.2.1"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.26.0",
43
- "@idosgames/module-sdk": "0.13.3",
44
- "@idosgames/react": "0.7.0",
42
+ "@idosgames/core": "0.26.2",
43
+ "@idosgames/module-sdk": "0.13.4",
44
+ "@idosgames/react": "0.7.2",
45
45
  "react": "19.2.7"
46
46
  },
47
47
  "contentHash": "03c011db581a59f3e98ea8696f113e02021db798e9a871be151d3e65726db2f8",
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.26.0",
36
- "@idosgames/module-sdk": "0.13.3",
37
- "@idosgames/react": "0.7.0",
35
+ "@idosgames/core": "0.26.2",
36
+ "@idosgames/module-sdk": "0.13.4",
37
+ "@idosgames/react": "0.7.2",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "d835f2923e281c625bd1888202c6e2df9005bd05ba54ed5793fbac2e03aab4dc",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.5"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.26.0",
38
- "@idosgames/module-sdk": "0.13.3",
39
- "@idosgames/react": "0.7.0",
37
+ "@idosgames/core": "0.26.2",
38
+ "@idosgames/module-sdk": "0.13.4",
39
+ "@idosgames/react": "0.7.2",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "2448dc72d4408919336d2f4011a8dd8903a913811c8ef00db2b64c21b77f5b52",
@@ -33,9 +33,9 @@
33
33
  "version": "0.1.2"
34
34
  },
35
35
  "dependencies": {
36
- "@idosgames/core": "0.26.0",
37
- "@idosgames/module-sdk": "0.13.3",
38
- "@idosgames/react": "0.7.0",
36
+ "@idosgames/core": "0.26.2",
37
+ "@idosgames/module-sdk": "0.13.4",
38
+ "@idosgames/react": "0.7.2",
39
39
  "react": "19.2.7"
40
40
  },
41
41
  "contentHash": "c374268f2fc7cfb1fb4a6073cceca7cb0666f85bc2f1c37bc4ba818233207b61",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.26.0",
38
- "@idosgames/module-sdk": "0.13.3",
39
- "@idosgames/react": "0.7.0",
37
+ "@idosgames/core": "0.26.2",
38
+ "@idosgames/module-sdk": "0.13.4",
39
+ "@idosgames/react": "0.7.2",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "f30292cdadf0595a434aaae91598c279add865f03c5ae9767d5b4227e9d479ba",
@@ -31,9 +31,9 @@
31
31
  "version": "0.1.3"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.26.0",
35
- "@idosgames/module-sdk": "0.13.3",
36
- "@idosgames/react": "0.7.0",
34
+ "@idosgames/core": "0.26.2",
35
+ "@idosgames/module-sdk": "0.13.4",
36
+ "@idosgames/react": "0.7.2",
37
37
  "react": "19.2.7"
38
38
  },
39
39
  "contentHash": "ab99622e4ba739d6e189e21c2e41c38125b2b2d140c2c63f670dd2205dca3efc",
@@ -8,7 +8,7 @@
8
8
  "catalog": {
9
9
  "type": "feature",
10
10
  "summary": "Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.",
11
- "description": "A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the mini-game module grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.",
11
+ "description": "A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (kit-game grants status at the end of each round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.",
12
12
  "provides": [
13
13
  "season screen with a tab per chain",
14
14
  "season timer and status bar",
@@ -36,12 +36,12 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.26.0",
40
- "@idosgames/module-sdk": "0.13.3",
41
- "@idosgames/react": "0.7.0",
39
+ "@idosgames/core": "0.26.2",
40
+ "@idosgames/module-sdk": "0.13.4",
41
+ "@idosgames/react": "0.7.2",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "e7d48360d17873bd96f35c06a96a69320d2802f688a71e47392cc7dbf2c1d893",
44
+ "contentHash": "ad95a47f070763556c554c73428e7b1c921f7c987a9960294f8464c5794315b2",
45
45
  "files": [
46
46
  {
47
47
  "path": "i18n.ts",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  {
67
67
  "path": "module.meta.json",
68
- "content": "{\n \"id\": \"season\",\n \"type\": \"feature\",\n \"summary\": \"Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.\",\n \"description\": \"A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the mini-game module grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.\",\n \"provides\": [\n \"season screen with a tab per chain\",\n \"season timer and status bar\",\n \"tier ladder with rewards\",\n \"claim all reached tiers\",\n \"VIP second prize on tiers\",\n \"hand-off to the VIP system\",\n \"season badge\",\n \"demo seasons\"\n ],\n \"tags\": [\n \"season\",\n \"battle-pass\",\n \"tiers\",\n \"progression\",\n \"live-ops\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
68
+ "content": "{\n \"id\": \"season\",\n \"type\": \"feature\",\n \"summary\": \"Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.\",\n \"description\": \"A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (kit-game grants status at the end of each round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.\",\n \"provides\": [\n \"season screen with a tab per chain\",\n \"season timer and status bar\",\n \"tier ladder with rewards\",\n \"claim all reached tiers\",\n \"VIP second prize on tiers\",\n \"hand-off to the VIP system\",\n \"season badge\",\n \"demo seasons\"\n ],\n \"tags\": [\n \"season\",\n \"battle-pass\",\n \"tiers\",\n \"progression\",\n \"live-ops\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
69
69
  }
70
70
  ]
71
71
  }
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.4"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.26.0",
40
- "@idosgames/module-sdk": "0.13.3",
41
- "@idosgames/react": "0.7.0",
39
+ "@idosgames/core": "0.26.2",
40
+ "@idosgames/module-sdk": "0.13.4",
41
+ "@idosgames/react": "0.7.2",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "e702aa4d3c44024f0c964d4b3c791599a8724bece9020895df2de3286ad204ff",
@@ -8,7 +8,7 @@
8
8
  "catalog": {
9
9
  "type": "feature",
10
10
  "summary": "Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.",
11
- "description": "A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}, the mini-game's round play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.",
11
+ "description": "A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.",
12
12
  "provides": [
13
13
  "tutorial overlay with a highlighted element",
14
14
  "hint bubble with next and skip",
@@ -32,12 +32,12 @@
32
32
  "version": "0.1.4"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.26.0",
36
- "@idosgames/module-sdk": "0.13.3",
37
- "@idosgames/react": "0.7.0",
35
+ "@idosgames/core": "0.26.2",
36
+ "@idosgames/module-sdk": "0.13.4",
37
+ "@idosgames/react": "0.7.2",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "da4dbd714014f6a0f2a2d761ac737dc2e70f5c466ec05f9cc51194d3cd87f40f",
40
+ "contentHash": "013277c81a5a5fe1d7454bb7ffe7da69ee5afab4b050cccf59d2e73a5cf48487",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
64
- "content": "{\n \"id\": \"tutorials\",\n \"type\": \"feature\",\n \"summary\": \"Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.\",\n \"description\": \"A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\\\"{AnchorID}\\\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}, the mini-game's round play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.\",\n \"provides\": [\n \"tutorial overlay with a highlighted element\",\n \"hint bubble with next and skip\",\n \"any element as an anchor via data-tutorial-anchor\",\n \"tutorial completion reward\",\n \"tutorials list with replay\",\n \"demo welcome tour\"\n ],\n \"tags\": [\"tutorial\", \"onboarding\", \"ftue\", \"coach-marks\", \"hints\", \"ui\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
64
+ "content": "{\n \"id\": \"tutorials\",\n \"type\": \"feature\",\n \"summary\": \"Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.\",\n \"description\": \"A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\\\"{AnchorID}\\\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.\",\n \"provides\": [\n \"tutorial overlay with a highlighted element\",\n \"hint bubble with next and skip\",\n \"any element as an anchor via data-tutorial-anchor\",\n \"tutorial completion reward\",\n \"tutorials list with replay\",\n \"demo welcome tour\"\n ],\n \"tags\": [\"tutorial\", \"onboarding\", \"ftue\", \"coach-marks\", \"hints\", \"ui\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
65
65
  }
66
66
  ]
67
67
  }
@@ -54,10 +54,10 @@
54
54
  "version": "0.1.1"
55
55
  },
56
56
  "dependencies": {
57
- "@idosgames/characters": "0.1.2",
58
- "@idosgames/core": "0.26.0",
59
- "@idosgames/module-sdk": "0.13.3",
60
- "@idosgames/react": "0.7.0",
57
+ "@idosgames/characters": "0.1.3",
58
+ "@idosgames/core": "0.26.2",
59
+ "@idosgames/module-sdk": "0.13.4",
60
+ "@idosgames/react": "0.7.2",
61
61
  "@idosgames/timeline": "0.1.0",
62
62
  "react": "19.2.7",
63
63
  "three": "0.185.1"
@@ -84,10 +84,10 @@
84
84
  "version": "0.7.4"
85
85
  },
86
86
  "dependencies": {
87
- "@idosgames/characters": "0.1.2",
88
- "@idosgames/core": "0.26.0",
89
- "@idosgames/module-sdk": "0.13.3",
90
- "@idosgames/react": "0.7.0",
87
+ "@idosgames/characters": "0.1.3",
88
+ "@idosgames/core": "0.26.2",
89
+ "@idosgames/module-sdk": "0.13.4",
90
+ "@idosgames/react": "0.7.2",
91
91
  "react": "19.2.7",
92
92
  "three": "0.185.1"
93
93
  },
@@ -39,9 +39,9 @@
39
39
  "version": "0.1.3"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.26.0",
43
- "@idosgames/module-sdk": "0.13.3",
44
- "@idosgames/react": "0.7.0",
42
+ "@idosgames/core": "0.26.2",
43
+ "@idosgames/module-sdk": "0.13.4",
44
+ "@idosgames/react": "0.7.2",
45
45
  "react": "19.2.7"
46
46
  },
47
47
  "contentHash": "c3a4b38be15260152a5244a06539639a1c7c08c207faf76d03baab43e639d148",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-generation-system",
3
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 — pick by `Variant` / `ContentType`: the\n model's `Variant` is its extension (`glb`, `fbx`, `splat`…), images are\n `thumbnail`, `front` / `left` / `back` / `right`, `uv`, `mask`. `rig-check`\n has no file at all: its answer is `OutputText` JSON.\n- Chains (`rig` → `retarget`, a model → `texture` / `convert`…) pass the earlier\n result as `SourceGenerationId` — the player's own finished generation, never\n a raw vendor id or a link. The feature, not the player, picks the operation.\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",
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| `generateFromTemplate(featureKey, files?, options?)` | A feature with `PlatformTemplateId`: run the platform template or trend with the player's pictures. | `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 — pick by `Variant` / `ContentType`: the\n model's `Variant` is its extension (`glb`, `fbx`, `splat`…), images are\n `thumbnail`, `front` / `left` / `back` / `right`, `uv`, `mask`. `rig-check`\n has no file at all: its answer is `OutputText` JSON.\n- Chains (`rig` → `retarget`, a model → `texture` / `convert`…) pass the earlier\n result as `SourceGenerationId` — the player's own finished generation, never\n a raw vendor id or a link. The feature, not the player, picks the operation.\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### A trend or template of the platform AI studio\n\nA feature with `PlatformTemplateId` (a template or trend from idosgames.com/ai) takes only the template's pictures\nand variables: the prompt, the tier form (aspect ratio, length…) and the price come from the server, and the prompt\nnever reaches the client. The plain generate action of such a feature fails with `AI_WRONG_ACTION`.\n\n```ts\nconst defs = await client.ai.getDefinitions();\nconst trend = defs.ok ? defs.data.Features?.[\"dance-trend\"] : undefined;\nif (trend?.PlatformTemplateId) {\n const job = await client.ai.generateFromTemplate(\n \"dance-trend\",\n { face: { Base64: photoBase64 } }, // keys = the template's inputs\n { variables: { tempo: \"fast\" } },\n );\n const done = job.ok ? await client.ai.waitForGeneration(job.data) : job;\n}\n```\n\nRefusals of the template itself: `TEMPLATE_INPUT_REQUIRED: <key>` (a required picture is missing),\n`TEMPLATE_VALUE_REQUIRED: <name>`, `TEMPLATE_BAD_VALUE: <name>` (a choice outside its list),\n`TEMPLATE_UNSUPPORTED` (an app cannot run this template yet); a template taken off the site answers `AI_UNAVAILABLE`.\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
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "currency-system",
3
3
  "description": "Convert between currencies in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.currency (CurrencyService): virtual-currency to virtual-currency (VC↔VC) conversion, and crypto-source conversion (crypto→VC or crypto→crypto). This is also the canonical home for the SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry cost-and-reward types used by every other module (Store, Character, Craft, Lootbox, Blockchain, …). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a currency-exchange screen, gold-to-gems conversion, crypto conversion, or otherwise touches client.currency, CurrencyService, ConvertResponse, CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or ResourceEntry — even if they don't name the module explicitly.",
4
- "content": "---\nname: currency-system\ndescription: >-\n Convert between currencies in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.currency (CurrencyService): virtual-currency to\n virtual-currency (VC↔VC) conversion, and crypto-source conversion\n (crypto→VC or crypto→crypto). This is also the canonical home for the\n SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry\n cost-and-reward types used by every other module (Store, Character, Craft,\n Lootbox, Blockchain, …). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n currency-exchange screen, gold-to-gems conversion, crypto conversion, or\n otherwise touches client.currency, CurrencyService, ConvertResponse,\n CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or\n ResourceEntry — even if they don't name the module explicitly.\n---\n\n# Currency system (iDosGames TS SDK)\n\nThe Currency module is small — two methods — but it's the module every other\nsystem rides on: it's the reference implementation for converting one balance\ninto another, and it's the canonical home for the **shared cost/reward\nprimitives** (`ResourceConsume`, `ResourceGrant`, `ResourceOperation`,\n`ResourceEntry`) that Store, Character, Craft, Lootbox, Blockchain, and others\nall use to describe \"this action costs X and grants Y.\" Read this skill once\nand the resource shapes in every other module's docs make sense by reference.\n\nEverything is **server-authoritative**: the client asks the backend to\nconvert, the backend validates status/rate/fee/limits and debits/credits, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate balances yourself, and you never precompute the rate, fee, or\nrounding client-side — the backend owns all of it.\n\nThis skill is for **using** the production `CurrencyService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(disabled conversion, unlisted target, currency under maintenance, daily\nlimit, insufficient funds) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Two currency kinds\n\n- **Virtual currency (VC)** — title-defined, integer balances (gold, gems,\n energy). Config: `VirtualCurrencyDefinition`.\n- **Crypto currency** — on-chain-backed, decimal balances (ETH, USDT, …).\n Config: `CryptoCurrencyDefinition`. Crypto balances/deposits/withdrawals are\n otherwise the Blockchain module's territory — see the blockchain-system\n skill; Currency only covers converting a crypto balance you already hold.\n\nBoth currency kinds share a `CurrencyType` tag (`\"Virtual\"` | `\"Crypto\"`) used\nthroughout requests/responses to disambiguate `CurrencyID`s that could\notherwise collide.\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 currency = client.currency; // the CurrencyService\n```\n\nEvery currency method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the 600ms throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Conversion from\n'X' is disabled\", \"is under maintenance\", \"is deprecated and cannot receive\nnew credits\", \"Amount below pair minimum\", \"Per-pair daily limit exceeded\",\ninsufficient balance).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------- |\n| `convert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | VC↔VC conversion only. Integer amounts. | `ConvertResponse` |\n| `cryptoConvert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | Crypto-**source** conversion (crypto→VC or crypto→crypto). Decimal amounts. | `CryptoConvertResponse` |\n\n`sourceAmount` for `convert` is a `number` (truncated to an integer via\n`Math.trunc` before sending — VC balances are integer). For `cryptoConvert` it\nis `Decimal | string | number` (a `decimal.js` `Decimal`, a numeric string, or\na number) — pass a string or `Decimal` for anything beyond safe-integer/float\nprecision; the SDK depends on `decimal.js`. Each method validates locally\nbefore any network call and rejects mismatched pairs:\n\n- `convert` rejects if either side is `CurrencyType.Crypto` — \"Convert\n supports VC↔VC only. Use cryptoConvert for crypto.\"\n- `cryptoConvert` rejects if **both** sides are `CurrencyType.Virtual` —\n \"cryptoConvert requires at least one crypto side. Use convert for VC↔VC.\"\n- Both reject source === target, empty IDs, non-positive amounts.\n- `cryptoConvert` additionally rejects a non-integer amount when the source\n side is `Virtual` (VC amounts must be whole) — \"Virtual currency source\n amount must be integer.\"\n\nOne server-side constraint the SDK preflight does **not** catch: the backend's\n`CryptoConvert` endpoint requires the **source** to be `Crypto`, and\n`Virtual→Crypto` is not supported by any endpoint (`Convert` explicitly fails\nVirtual→Crypto with \"Virtual→Crypto conversion is not supported.\"). So the\nonly valid pairings are `convert` for VC→VC and `cryptoConvert` for crypto→VC\n/ crypto→crypto; a Virtual-source `cryptoConvert` passes the local check but\ncomes back `reason: \"server\"` (\"CryptoConvert requires source to be Crypto.\nUse Convert for VC↔VC.\").\n\n`transactionID` is optional; omit it and the SDK generates a unique one per\ncall (`convert_<sourceType>_<sourceID>_to_<targetType>_<targetID>_<uuid>` /\n`crypto_convert_...`). The backend folds `TransactionID` into its idempotency\nkey (`CurrencyConvert:<key>` / `CurrencyCryptoConvert:<key>`, where `<key>` is\n`TransactionID` verbatim when you supply one), stored per `(userID, reason)`\nfor 7 days. **Retrying with the same `transactionID` is safe** — the server\ndetects the replay and returns the stored result instead of charging again.\nTwo calls with different (e.g. auto-generated) IDs are two real conversions.\n\nOn success, both methods **mirror the confirmed debit/credit into the cache\nand emit an event** — you don't apply anything by hand. Read updated balances\nstraight from the cache.\n\n### Non-obvious server behavior worth knowing before you build UI\n\n- **Rate resolution**: `Automatic` mode divides the source's `ValueInUSD` by\n the target's `ValueInUSD` (`rate = src.ValueInUSD / tgt.ValueInUSD`);\n `Manual` mode uses the fixed `Rate` pinned on that specific\n `ConversionTarget`. Which mode applies is a property of the **source**\n currency (`Conversion.RateMode`), not the pair.\n- **Fee comes off first, then the rate, then rounding**: `fee = sourceAmount *\nFee`; `net = sourceAmount - fee`; `output = net * rate`. Order matters for\n previews.\n- **`Fee` is a SHARE, not a percent**: `0.05` means 5%. This is the unit used\n across the whole platform (`RateSpec`), and the value is passed straight into\n `RateSpec.Rate` with no rescaling — there is no division by 100 anywhere. The\n field is deliberately not called `FeePercent`; the `Percent` suffix is banned\n platform-wide because it was the source of 0..1 vs 0..100 confusion. Show\n percents in a UI by multiplying by 100. A `Fee` of `1.0` is a deliberate ban\n on converting that currency, and is not clamped away.\n- **Rounding is always truncation toward zero, never nearest/ceiling.** VC↔VC\n truncates the final `output` to a `long`. Crypto-source conversions keep\n full decimal precision throughout _except_ when the target is Virtual, where\n the decimal `output` is floored (`Math.Floor`) to a `long`. A conversion\n whose result rounds to 0 (dust) is rejected rather than silently granting\n nothing.\n- **Currency status gates asymmetrically**: `Maintenance` blocks the currency\n on either side; `Deprecated` blocks it only as a **target** (can't receive\n new credits) — a deprecated currency can still be spent down as a _source_.\n- **Two independent limit layers can reject the same call**: the per-pair\n `ConversionTarget.MinAmount` / `MaxAmount` / `DailyLimit` (scoped to this\n exact source→target pair), and the source currency's own\n `Economy.MinBalance` / `MaxBalance` / `DailyEarnLimit` / `DailySpendLimit`\n (scoped to the whole currency, across every way it can change). Either can\n fail independently — don't assume passing one means the other passed.\n\n## Reading state and reacting to changes\n\n```ts\n// Virtual currency balance (integer):\nclient.data.user.getVirtualCurrencyAmount(\"coins\"); // number\n\n// Crypto currency balance (decimal-as-string):\nclient.data.user.getCryptoCurrencyAmount(\"eth\"); // string, e.g. \"0.05\"\n\n// Currency catalog (config). Populated by client.title.getCurrencyDefinitions()\n// (emits \"title:currencyDefinitionsReceived\"), with a fallback to the `Currency`\n// section of the full title public configuration if that's been loaded:\nconst defs = client.data.config.currencyDefinitions; // CurrencyDefinitions | undefined\ndefs?.VirtualCurrencies?.[\"coins\"]?.Conversion?.Targets;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `currency:converted` → `ConvertResponse`\n- `currency:cryptoConverted` → `CryptoConvertResponse`\n\nNeither `convert` nor `cryptoConvert` has its own coarse `user:currencyUpdated`\nevent — the balance change instead rides through the same resource pipeline\nevery other module uses. `convert` (VC↔VC) always applies through the integer\nresource pipeline, so it fires `user:virtualCurrencyUpdated` +\n`user:inventoryUpdated` (plus the umbrella `user:anyUpdated`) for both sides.\n`cryptoConvert` splits by side: a `Virtual` leg goes through the same\nresource pipeline (same events as above for that leg only); a `Crypto` leg\ngoes through a separate decimal patch that fires only\n`user:inventoryUpdated` + `user:anyUpdated` (no\n`user:virtualCurrencyUpdated`, since no VC balance changed). Listen at\nwhichever granularity suits your UI: the specific `currency:*` event for a\ntoast/confirmation, the coarse `user:*` event for a \"re-render balances\" hook.\n\n```ts\nconst off = client.on(\"currency:converted\", (r) => {\n console.log(\n `Spent ${r.SourceSpent} ${r.SourceID} -> got ${r.TargetCredited} ${r.TargetID}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Convert gold to gems (VC↔VC)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // 100 (integer, includes the fee)\nres.data.FeeAmount; // e.g. 10 (integer, source-currency units — 10% fee here)\nres.data.TargetCredited; // e.g. 45 (integer: (100 - 10) * 0.5, truncated)\nres.data.RateApplied; // decimal-as-string, e.g. \"0.5\"\n// Balances are already updated in the cache:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Convert a crypto balance to virtual currency\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\nimport Decimal from \"decimal.js\";\n\nconst res = await client.currency.cryptoConvert(\n CurrencyType.Crypto,\n \"usdt\",\n CurrencyType.Virtual,\n \"gems\",\n new Decimal(\"2.50\"), // decimal source amount\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // \"2.50\" (decimal string)\nres.data.TargetCredited; // \"500\" (decimal string; VC side is still integer-valued and floored)\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Read a cost/reward breakdown from another module's response\n\nCurrency doesn't return `ResourceOperation` itself (its responses are flat\n`ConvertResponse`/`CryptoConvertResponse`), but almost every other module's\nresponse embeds one under a `Resources` field — e.g. a Store purchase, a\nCharacter upgrade, a Blockchain deposit. Once you've read this skill you can\nread any of them the same way:\n\n```ts\nimport type { ResourceOperation } from \"@idosgames/core\";\n\nfunction summarize(op: ResourceOperation | null | undefined) {\n const spent = op?.Consume?.Standard?.Entries ?? [];\n const gained = op?.Grant?.Standard?.Entries ?? [];\n for (const e of spent)\n console.log(`-${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n for (const e of gained)\n console.log(`+${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n}\n```\n\n`Standard` is already the server-resolved final amount (premium\ndiscounts/bonuses folded in) — don't re-derive it from `PremiumDiscounts` /\n`PremiumTiers`. See [references/data-model.md](references/data-model.md) for\nthe full shape and every field.\n\n### Edge case: rejected pairing (client-side, no network call)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Crypto, // wrong method for a crypto side\n \"eth\",\n CurrencyType.Virtual,\n \"coins\",\n 1,\n);\n// res.ok === false, res.reason === \"client\" — rejected locally, no round-trip.\n// Use cryptoConvert instead.\n```\n\n### Edge case: not logged in\n\n```ts\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res.ok === false, res.reason === \"unauthorized\"\n```\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n client-side (the auto-generated `TransactionID`), so two separate calls are\n two real operations — a double-clicked \"Convert\" can charge twice. Disable\n the control while a call is in flight. (Firing the same endpoint again\n 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.)\n- **`convert` and `cryptoConvert` are not interchangeable.** `convert` is\n VC↔VC only (integer amounts); `cryptoConvert` requires at least one crypto\n side (decimal amounts) and rejects a VC↔VC pair. Both reject mismatched\n calls with `reason: \"client\"` before any network round-trip.\n- **Amount precision matters.** VC amounts are always integers — `convert`\n truncates via `Math.trunc`. Crypto amounts are decimal; pass a `Decimal` or\n numeric string for `cryptoConvert` rather than a JS `number` once you're\n near float precision limits (the SDK's own crypto math uses `decimal.js`\n throughout).\n- **Rounding always favors the house, never the player.** Both the VC↔VC and\n the crypto→VC paths truncate/floor the credited amount down — there is no\n \"round to nearest.\" A tiny source amount can legitimately convert to 0\n target units, which the backend rejects outright rather than granting a\n free-rounding credit.\n- **`RateApplied`/`FeeAmount` are informational, not something to\n precompute.** The backend enforces the title's configured\n `CurrencyConversion` rules (`Enabled`, `RateMode`, `Fee`, whitelisted\n `Targets` with their own `MinAmount`/`MaxAmount`/`DailyLimit`) — read the\n actual applied numbers off the response rather than estimating client-side.\n- **A conversion can fail on the currency's global limits even if the pair\n looks fine.** `Economy.MinBalance`/`MaxBalance`/`DailyEarnLimit`/\n `DailySpendLimit` apply on top of (and independently of) the pair-specific\n `MinAmount`/`MaxAmount`/`DailyLimit` — show whichever `error` string comes\n back rather than trying to pre-validate both layers yourself.\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\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — the full\n`CurrencyDefinitions` config shape (virtual + crypto), the backend conversion\nformulas transcribed from source, and the complete, canonical documentation of\nthe shared `ResourceConsume` / `ResourceGrant` / `ResourceOperation` /\n`ResourceEntry` types used across the whole SDK. Read it before building\ncost/reward UI in any other module (Store offers, Character upgrades, Craft\nrecipes, Lootbox prices, Blockchain deposits/withdrawals all describe their\ncosts and payouts with these same shapes).\n",
4
+ "content": "---\nname: currency-system\ndescription: >-\n Convert between currencies in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.currency (CurrencyService): virtual-currency to\n virtual-currency (VC↔VC) conversion, and crypto-source conversion\n (crypto→VC or crypto→crypto). This is also the canonical home for the\n SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry\n cost-and-reward types used by every other module (Store, Character, Craft,\n Lootbox, Blockchain, …). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n currency-exchange screen, gold-to-gems conversion, crypto conversion, or\n otherwise touches client.currency, CurrencyService, ConvertResponse,\n CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or\n ResourceEntry — even if they don't name the module explicitly.\n---\n\n# Currency system (iDosGames TS SDK)\n\nThe Currency module is small — two methods — but it's the module every other\nsystem rides on: it's the reference implementation for converting one balance\ninto another, and it's the canonical home for the **shared cost/reward\nprimitives** (`ResourceConsume`, `ResourceGrant`, `ResourceOperation`,\n`ResourceEntry`) that Store, Character, Craft, Lootbox, Blockchain, and others\nall use to describe \"this action costs X and grants Y.\" Read this skill once\nand the resource shapes in every other module's docs make sense by reference.\n\nEverything is **server-authoritative**: the client asks the backend to\nconvert, the backend validates status/rate/fee/limits and debits/credits, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate balances yourself, and you never precompute the rate, fee, or\nrounding client-side — the backend owns all of it.\n\nThis skill is for **using** the production `CurrencyService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(disabled conversion, unlisted target, currency under maintenance, daily\nlimit, insufficient funds) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Two currency kinds\n\n- **Virtual currency (VC)** — title-defined, integer balances (gold, gems,\n energy). Config: `VirtualCurrencyDefinition`.\n- **Crypto currency** — on-chain-backed, decimal balances (ETH, USDT, …).\n Config: `CryptoCurrencyDefinition`. Crypto balances/deposits/withdrawals are\n otherwise the Blockchain module's territory — see the blockchain-system\n skill; Currency only covers converting a crypto balance you already hold.\n\nBoth currency kinds share a `CurrencyType` tag (`\"Virtual\"` | `\"Crypto\"`) used\nthroughout requests/responses to disambiguate `CurrencyID`s that could\notherwise collide.\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 currency = client.currency; // the CurrencyService\n```\n\nEvery currency method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the 600ms throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Conversion from\n'X' is disabled\", \"is under maintenance\", \"is deprecated and cannot receive\nnew credits\", \"Amount below pair minimum\", \"Per-pair daily limit exceeded\",\ninsufficient balance).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------- |\n| `convert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | VC↔VC conversion only. Integer amounts. | `ConvertResponse` |\n| `cryptoConvert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | Crypto-**source** conversion (crypto→VC or crypto→crypto). Decimal amounts. | `CryptoConvertResponse` |\n| `spend(spendID, { selectedOptionID?, relatedEntityID? }?)` | Pays for an action of the game (a revive, other upgrade cards) with the price of `Currency.Spends[spendID]`. Charges, grants nothing. | `SpendResponse` |\n\n`sourceAmount` for `convert` is a `number` (truncated to an integer via\n`Math.trunc` before sending — VC balances are integer). For `cryptoConvert` it\nis `Decimal | string | number` (a `decimal.js` `Decimal`, a numeric string, or\na number) — pass a string or `Decimal` for anything beyond safe-integer/float\nprecision; the SDK depends on `decimal.js`. Each method validates locally\nbefore any network call and rejects mismatched pairs:\n\n- `convert` rejects if either side is `CurrencyType.Crypto` — \"Convert\n supports VC↔VC only. Use cryptoConvert for crypto.\"\n- `cryptoConvert` rejects if **both** sides are `CurrencyType.Virtual` —\n \"cryptoConvert requires at least one crypto side. Use convert for VC↔VC.\"\n- Both reject source === target, empty IDs, non-positive amounts.\n- `cryptoConvert` additionally rejects a non-integer amount when the source\n side is `Virtual` (VC amounts must be whole) — \"Virtual currency source\n amount must be integer.\"\n\nOne server-side constraint the SDK preflight does **not** catch: the backend's\n`CryptoConvert` endpoint requires the **source** to be `Crypto`, and\n`Virtual→Crypto` is not supported by any endpoint (`Convert` explicitly fails\nVirtual→Crypto with \"Virtual→Crypto conversion is not supported.\"). So the\nonly valid pairings are `convert` for VC→VC and `cryptoConvert` for crypto→VC\n/ crypto→crypto; a Virtual-source `cryptoConvert` passes the local check but\ncomes back `reason: \"server\"` (\"CryptoConvert requires source to be Crypto.\nUse Convert for VC↔VC.\").\n\n`transactionID` is optional; omit it and the SDK generates a unique one per\ncall (`convert_<sourceType>_<sourceID>_to_<targetType>_<targetID>_<uuid>` /\n`crypto_convert_...`). The backend folds `TransactionID` into its idempotency\nkey (`CurrencyConvert:<key>` / `CurrencyCryptoConvert:<key>`, where `<key>` is\n`TransactionID` verbatim when you supply one), stored per `(userID, reason)`\nfor 7 days. **Retrying with the same `transactionID` is safe** — the server\ndetects the replay and returns the stored result instead of charging again.\nTwo calls with different (e.g. auto-generated) IDs are two real conversions.\n\nOn success, both methods **mirror the confirmed debit/credit into the cache\nand emit an event** — you don't apply anything by hand. Read updated balances\nstraight from the cache.\n\n### Non-obvious server behavior worth knowing before you build UI\n\n- **Rate resolution**: `Automatic` mode divides the source's `ValueInUSD` by\n the target's `ValueInUSD` (`rate = src.ValueInUSD / tgt.ValueInUSD`);\n `Manual` mode uses the fixed `Rate` pinned on that specific\n `ConversionTarget`. Which mode applies is a property of the **source**\n currency (`Conversion.RateMode`), not the pair.\n- **Fee comes off first, then the rate, then rounding**: `fee = sourceAmount *\nFee`; `net = sourceAmount - fee`; `output = net * rate`. Order matters for\n previews.\n- **`Fee` is a SHARE, not a percent**: `0.05` means 5%. This is the unit used\n across the whole platform (`RateSpec`), and the value is passed straight into\n `RateSpec.Rate` with no rescaling — there is no division by 100 anywhere. The\n field is deliberately not called `FeePercent`; the `Percent` suffix is banned\n platform-wide because it was the source of 0..1 vs 0..100 confusion. Show\n percents in a UI by multiplying by 100. A `Fee` of `1.0` is a deliberate ban\n on converting that currency, and is not clamped away.\n- **Rounding is always truncation toward zero, never nearest/ceiling.** VC↔VC\n truncates the final `output` to a `long`. Crypto-source conversions keep\n full decimal precision throughout _except_ when the target is Virtual, where\n the decimal `output` is floored (`Math.Floor`) to a `long`. A conversion\n whose result rounds to 0 (dust) is rejected rather than silently granting\n nothing.\n- **Currency status gates asymmetrically**: `Maintenance` blocks the currency\n on either side; `Deprecated` blocks it only as a **target** (can't receive\n new credits) — a deprecated currency can still be spent down as a _source_.\n- **Two independent limit layers can reject the same call**: the per-pair\n `ConversionTarget.MinAmount` / `MaxAmount` / `DailyLimit` (scoped to this\n exact source→target pair), and the source currency's own\n `Economy.MinBalance` / `MaxBalance` / `DailyEarnLimit` / `DailySpendLimit`\n (scoped to the whole currency, across every way it can change). Either can\n fail independently — don't assume passing one means the other passed.\n\n## Reading state and reacting to changes\n\n```ts\n// Virtual currency balance (integer):\nclient.data.user.getVirtualCurrencyAmount(\"coins\"); // number\n\n// Crypto currency balance (decimal-as-string):\nclient.data.user.getCryptoCurrencyAmount(\"eth\"); // string, e.g. \"0.05\"\n\n// Currency catalog (config). Populated by client.title.getCurrencyDefinitions()\n// (emits \"title:currencyDefinitionsReceived\"), with a fallback to the `Currency`\n// section of the full title public configuration if that's been loaded:\nconst defs = client.data.config.currencyDefinitions; // CurrencyDefinitions | undefined\ndefs?.VirtualCurrencies?.[\"coins\"]?.Conversion?.Targets;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `currency:converted` → `ConvertResponse`\n- `currency:cryptoConverted` → `CryptoConvertResponse`\n- `currency:spent` → `SpendResponse`\n\nNeither `convert` nor `cryptoConvert` has its own coarse `user:currencyUpdated`\nevent — the balance change instead rides through the same resource pipeline\nevery other module uses. `convert` (VC↔VC) always applies through the integer\nresource pipeline, so it fires `user:virtualCurrencyUpdated` +\n`user:inventoryUpdated` (plus the umbrella `user:anyUpdated`) for both sides.\n`cryptoConvert` splits by side: a `Virtual` leg goes through the same\nresource pipeline (same events as above for that leg only); a `Crypto` leg\ngoes through a separate decimal patch that fires only\n`user:inventoryUpdated` + `user:anyUpdated` (no\n`user:virtualCurrencyUpdated`, since no VC balance changed). Listen at\nwhichever granularity suits your UI: the specific `currency:*` event for a\ntoast/confirmation, the coarse `user:*` event for a \"re-render balances\" hook.\n\n```ts\nconst off = client.on(\"currency:converted\", (r) => {\n console.log(\n `Spent ${r.SourceSpent} ${r.SourceID} -> got ${r.TargetCredited} ${r.TargetID}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Pay for an action of the game (a revive, a reroll)\n\nThe title config lists the spends (`Currency.Spends`: id → price options). The client names the\nspend, never the amount, so a player cannot pay less or count a fake spend toward \"spend N\" quests.\nPrices are virtual currencies, items or rewarded-video credit; a store payment, Aspis (Credits) and\ncrypto are refused. Do the action only after `ok`. Show the button only when the spend exists, and\nread its price from the config rather than hard-coding it:\n\n```ts\nconst spends = client.data.config.getSection<CurrencyDefinitions>(\"Currency\")?.Spends;\nif (spends?.[\"revive\"]) {\n // the same key on a retry (a double tap, a timeout): the server answers paid without charging twice\n const res = await client.currency.spend(\"revive\", { relatedEntityID: `run-${runId}-revive` });\n if (res.ok) reviveHero();\n}\n```\n\nA store offer cannot do this: an offer must grant something.\n\n### Convert gold to gems (VC↔VC)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // 100 (integer, includes the fee)\nres.data.FeeAmount; // e.g. 10 (integer, source-currency units — 10% fee here)\nres.data.TargetCredited; // e.g. 45 (integer: (100 - 10) * 0.5, truncated)\nres.data.RateApplied; // decimal-as-string, e.g. \"0.5\"\n// Balances are already updated in the cache:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Convert a crypto balance to virtual currency\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\nimport Decimal from \"decimal.js\";\n\nconst res = await client.currency.cryptoConvert(\n CurrencyType.Crypto,\n \"usdt\",\n CurrencyType.Virtual,\n \"gems\",\n new Decimal(\"2.50\"), // decimal source amount\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // \"2.50\" (decimal string)\nres.data.TargetCredited; // \"500\" (decimal string; VC side is still integer-valued and floored)\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Read a cost/reward breakdown from another module's response\n\nCurrency doesn't return `ResourceOperation` itself (its responses are flat\n`ConvertResponse`/`CryptoConvertResponse`), but almost every other module's\nresponse embeds one under a `Resources` field — e.g. a Store purchase, a\nCharacter upgrade, a Blockchain deposit. Once you've read this skill you can\nread any of them the same way:\n\n```ts\nimport type { ResourceOperation } from \"@idosgames/core\";\n\nfunction summarize(op: ResourceOperation | null | undefined) {\n const spent = op?.Consume?.Standard?.Entries ?? [];\n const gained = op?.Grant?.Standard?.Entries ?? [];\n for (const e of spent)\n console.log(`-${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n for (const e of gained)\n console.log(`+${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n}\n```\n\n`Standard` is already the server-resolved final amount (premium\ndiscounts/bonuses folded in) — don't re-derive it from `PremiumDiscounts` /\n`PremiumTiers`. See [references/data-model.md](references/data-model.md) for\nthe full shape and every field.\n\n### Edge case: rejected pairing (client-side, no network call)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Crypto, // wrong method for a crypto side\n \"eth\",\n CurrencyType.Virtual,\n \"coins\",\n 1,\n);\n// res.ok === false, res.reason === \"client\" — rejected locally, no round-trip.\n// Use cryptoConvert instead.\n```\n\n### Edge case: not logged in\n\n```ts\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res.ok === false, res.reason === \"unauthorized\"\n```\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n client-side (the auto-generated `TransactionID`), so two separate calls are\n two real operations — a double-clicked \"Convert\" can charge twice. Disable\n the control while a call is in flight. (Firing the same endpoint again\n 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.)\n- **`convert` and `cryptoConvert` are not interchangeable.** `convert` is\n VC↔VC only (integer amounts); `cryptoConvert` requires at least one crypto\n side (decimal amounts) and rejects a VC↔VC pair. Both reject mismatched\n calls with `reason: \"client\"` before any network round-trip.\n- **Amount precision matters.** VC amounts are always integers — `convert`\n truncates via `Math.trunc`. Crypto amounts are decimal; pass a `Decimal` or\n numeric string for `cryptoConvert` rather than a JS `number` once you're\n near float precision limits (the SDK's own crypto math uses `decimal.js`\n throughout).\n- **Rounding always favors the house, never the player.** Both the VC↔VC and\n the crypto→VC paths truncate/floor the credited amount down — there is no\n \"round to nearest.\" A tiny source amount can legitimately convert to 0\n target units, which the backend rejects outright rather than granting a\n free-rounding credit.\n- **`RateApplied`/`FeeAmount` are informational, not something to\n precompute.** The backend enforces the title's configured\n `CurrencyConversion` rules (`Enabled`, `RateMode`, `Fee`, whitelisted\n `Targets` with their own `MinAmount`/`MaxAmount`/`DailyLimit`) — read the\n actual applied numbers off the response rather than estimating client-side.\n- **A conversion can fail on the currency's global limits even if the pair\n looks fine.** `Economy.MinBalance`/`MaxBalance`/`DailyEarnLimit`/\n `DailySpendLimit` apply on top of (and independently of) the pair-specific\n `MinAmount`/`MaxAmount`/`DailyLimit` — show whichever `error` string comes\n back rather than trying to pre-validate both layers yourself.\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\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — the full\n`CurrencyDefinitions` config shape (virtual + crypto), the backend conversion\nformulas transcribed from source, and the complete, canonical documentation of\nthe shared `ResourceConsume` / `ResourceGrant` / `ResourceOperation` /\n`ResourceEntry` types used across the whole SDK. Read it before building\ncost/reward UI in any other module (Store offers, Character upgrades, Craft\nrecipes, Lootbox prices, Blockchain deposits/withdrawals all describe their\ncosts and payouts with these same shapes).\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-compose-modules",
3
3
  "description": "Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards, quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system, add a second game, switch between the lobby and game modes, share currency/inventory across modules, or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi, shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module).",
4
- "content": "---\nname: idosgames-compose-modules\ndescription: >-\n Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards,\n quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this\n whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system,\n add a second game, switch between the lobby and game modes, share currency/inventory across modules,\n or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi,\n shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared\n state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single\n module).\n---\n\n# Putting an app together: the base + modules\n\nEvery project is **the base + modules**. The base is sign-in and the lobby and comes from the SDK\n(`@idosgames/app-shell`: `createBaseModule`, `DefaultLoginScreen`, `DefaultPlayAccessScreen`); the project\nconfigures it in `src/app.config.ts` (where the player lands, the tab order, guest sign-in), `src/ui.config.ts`\n(the look) and `src/main.tsx`. Everything else is a module in `src/modules/<id>/`:\n\n- **systems** (`type: feature`) — one per player-facing module of the engine: store, premium (VIP),\n character, inventory, lootboxes, boosts, leaderboard, rewards (daily), quests, collections, craft,\n marketplace, deals, events, coop-events, season, friends, chat, invite, pvp, multiplayer, profile,\n tutorials, workshop, ai, mailbox. Each registers its screen as a **feature** → a tab of the lobby.\n The base installs only six of them into every new project (`inBase: true`: profile, inventory,\n invite, friends, quests, mailbox); every other system is installed only when the app needs it — a game\n brings the ones it lists in `uses`, and you install any other one as soon as the task needs what\n it `provides` (InstallModule / `get_module`) — the user does not have to name the module; reuse it\n instead of writing that feature again. Never install one the task does not need.\n- **games** (`type: template`) — board-game, idle-rpg, voxelcraft, mini-game, or the game's own. Each\n registers a **route** → a \"Play\" button in the lobby. A game that plugs into a system lists it in\n `uses` in its module.meta.json (`ctx.multiplayer` → `\"multiplayer\"`, `ctx.content` → `\"workshop\"`;\n `npm run check` enforces those two) — install those together with the game.\n\n## Register modules\n\n```ts\n// src/modules.ts — the platform regenerates it; by hand it is imports + the array\nexport const modules: Module[] = [\n storeModule, // systems: lobby tabs\n characterModule,\n leaderboardModule,\n boardGameModule, // games: \"Play\"\n idleRpgModule,\n];\n```\n\n`src/main.tsx` mounts `[createBaseModule(appConfig, …), ...modules]`. After sign-in the player lands in the game that\nlives inside the lobby, or in the lobby when the games take the whole screen (`src/app.config.ts`\n`start`: `\"game\"` — the default, `\"lobby\"`, or a game's mode id). \"Play\" switches the host to the\ngame's mode: only the active mode's scene is mounted and ticking; the rest are suspended (their RAF\nstops). This is why two different engines (Three + Phaser) live in one project — they never render\nat the same time. Two kinds of game (owner's decision 03.10.2026 — chosen per game, never for all):\n\n- `ctx.registerRoute({ …, inLobby: true })` — the game sits INSIDE the lobby (idle-rpg): the base\n keeps the balances on top and the tabs at the bottom (host panel slots `header`/`footer` — the\n game is laid out between them, it does nothing for it); \"Play\" is the game, any other tab opens\n that screen, the app starts in it. A game with its own bar to the systems hides it when\n `ctx.sharedUi.shouldDraw(\"menu\")` is false (idle-rpg does).\n- without it (the default) — the game takes the whole screen (voxelcraft, board-game, mini-game):\n \"Play\" in the lobby launches it, over it the base draws only a small \"To the lobby\" button.\n A game with its own way back (a pause menu with \"To the lobby\" → `ctx.lobby.open()`, and a pause\n button on touch screens) says `ownExit: true` in its route — then the base draws nothing over it\n (voxelcraft).\n\n- A system tab is hidden while the title has nothing for it (no store offers, no heroes…): the module\n registers with `available: false` and calls `ctx.features.setAvailable(id, ok, reason)`.\n- `ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type);\n the lobby builds \"Play\" from the `game` ones. Switch with `ctx.navigate(id)` (`\"lobby\"` is the base).\n- Open another system's screen by id: `ctx.features.open(\"store\")` — from a game too (the host shows\n it in a window while the lobby is off screen). Never import another module.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode and in the shop — same\n `client.currency`, same cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files. The client and the event bus are the seams\n between them — which is what keeps a module reusable in another game. Code several of THIS game's\n modules need (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a\n module (see idosgames-project-structure).\n\n## Shared chrome: roles, and the base takes them\n\nBalances, the crypto wallet, the status line, \"Log out\" / player ID, the menu of features and the\nswitch between modes are **shared-UI roles** (`currency-bar | wallet | status | account | menu |\nmodes`). A module that draws one for EVERY mode declares it statically on the module object —\nthe base does:\n\n```ts\ndefineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n /* the lobby: balances, wallet, profile, tabs, \"Play\" */\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider wins,\na conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`. A game draws its own\ncopy only while nobody took the role. So:\n\n- Next to the base a game hides its own balances and wallet by itself. Do NOT edit a catalog game to\n remove them: it does that itself, and an edit marks it customized (no more catalog updates).\n- `status` — the line of a game's own actions (\"Rolling…\") — stays with each game.\n- A game installed without a base stays complete — nobody owns a role, it draws everything, and the\n host draws its own nav of routes, ☰ and \"Log out\" (those are the host-drawn roles `modes`, `menu`,\n `account`).\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n\n**Layout is the host's job.** A game's overlay fills the screen under the base's small button; a\nscene's own DOM HUD can keep clear of host chrome with the CSS variables on the host root —\n`top: calc(var(--idos-safe-top, 0px) + 8px)`, `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)`\n(voxelcraft's hotbar does this). Both are 0 when nothing is pinned. `hud`-slot panels are wrapped in\n`display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let clicks\nthrough to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference app\n\nThe base + the systems + `board-game` + `idle-rpg`:\n\n- sign-in → the lobby: balances, wallet and profile on top, the systems as tabs, \"Play\" with two games;\n- \"Play\" → one engine scene mounted at a time; the lobby's tabs stay around an `inLobby` game, a\n full-screen game gets the button back;\n- the games hide their own balances and wallet (the lobby has them); their status lines stay theirs;\n- currency changed in one mode is immediately visible in the others and in the lobby; levelling a hero\n in Idle RPG shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
4
+ "content": "---\nname: idosgames-compose-modules\ndescription: >-\n Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards,\n quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this\n whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system,\n add a second game, switch between the lobby and game modes, share currency/inventory across modules,\n or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi,\n shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared\n state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single\n module).\n---\n\n# Putting an app together: the base + modules\n\nEvery project is **the base + modules**. The base is sign-in and the lobby and comes from the SDK\n(`@idosgames/app-shell`: `createBaseModule`, `DefaultLoginScreen`, `DefaultPlayAccessScreen`); the project\nconfigures it in `src/app.config.ts` (where the player lands, the tab order, guest sign-in), `src/ui.config.ts`\n(the look) and `src/main.tsx`. Everything else is a module in `src/modules/<id>/`:\n\n- **systems** (`type: feature`) — one per player-facing module of the engine: store, premium (VIP),\n character, inventory, lootboxes, boosts, leaderboard, rewards (daily), quests, collections, craft,\n marketplace, deals, events, coop-events, season, friends, chat, invite, pvp, multiplayer, profile,\n tutorials, workshop, ai, mailbox. Each registers its screen as a **feature** → a tab of the lobby.\n The base installs only six of them into every new project (`inBase: true`: profile, inventory,\n invite, friends, quests, mailbox); every other system is installed only when the app needs it — a game\n brings the ones it lists in `uses`, and you install any other one as soon as the task needs what\n it `provides` (InstallModule / `get_module`) — the user does not have to name the module; reuse it\n instead of writing that feature again. Never install one the task does not need.\n- **games** (`type: template`) — board-game, idle-rpg, voxelcraft, kit-game, or the game's own. Each\n registers a **route** → a \"Play\" button in the lobby. A game that plugs into a system lists it in\n `uses` in its module.meta.json (`ctx.multiplayer` → `\"multiplayer\"`, `ctx.content` → `\"workshop\"`;\n `npm run check` enforces those two) — install those together with the game.\n\n## Register modules\n\n```ts\n// src/modules.ts — the platform regenerates it; by hand it is imports + the array\nexport const modules: Module[] = [\n storeModule, // systems: lobby tabs\n characterModule,\n leaderboardModule,\n boardGameModule, // games: \"Play\"\n idleRpgModule,\n];\n```\n\n`src/main.tsx` mounts `[createBaseModule(appConfig, …), ...modules]`. After sign-in the player lands in the game that\nlives inside the lobby, or in the lobby when the games take the whole screen (`src/app.config.ts`\n`start`: `\"game\"` — the default, `\"lobby\"`, or a game's mode id). \"Play\" switches the host to the\ngame's mode: only the active mode's scene is mounted and ticking; the rest are suspended (their RAF\nstops). This is why two different engines (Three + Phaser) live in one project — they never render\nat the same time. Two kinds of game (owner's decision 03.10.2026 — chosen per game, never for all):\n\n- `ctx.registerRoute({ …, inLobby: true })` — the game sits INSIDE the lobby (idle-rpg): the base\n keeps the balances on top and the tabs at the bottom (host panel slots `header`/`footer` — the\n game is laid out between them, it does nothing for it); \"Play\" is the game, any other tab opens\n that screen, the app starts in it. A game with its own bar to the systems hides it when\n `ctx.sharedUi.shouldDraw(\"menu\")` is false (idle-rpg does).\n- without it (the default) — the game takes the whole screen (voxelcraft, kit-game):\n \"Play\" in the lobby launches it, over it the base draws only a small \"To the lobby\" button.\n A game with its own way back (a pause menu with \"To the lobby\" → `ctx.lobby.open()`, and a pause\n button on touch screens) says `ownExit: true` in its route — then the base draws nothing over it\n (voxelcraft).\n\n- A system tab is hidden while the title has nothing for it (no store offers, no heroes…): the module\n registers with `available: false` and calls `ctx.features.setAvailable(id, ok, reason)`.\n- `ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type);\n the lobby builds \"Play\" from the `game` ones. Switch with `ctx.navigate(id)` (`\"lobby\"` is the base).\n- Open another system's screen by id: `ctx.features.open(\"store\")` — from a game too (the host shows\n it in a window while the lobby is off screen). Never import another module.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode and in the shop — same\n `client.currency`, same cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files. The client and the event bus are the seams\n between them — which is what keeps a module reusable in another game. Code several of THIS game's\n modules need (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a\n module (see idosgames-project-structure).\n\n## Shared chrome: roles, and the base takes them\n\nBalances, the crypto wallet, the status line, \"Log out\" / player ID, the menu of features and the\nswitch between modes are **shared-UI roles** (`currency-bar | wallet | status | account | menu |\nmodes`). A module that draws one for EVERY mode declares it statically on the module object —\nthe base does:\n\n```ts\ndefineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n /* the lobby: balances, wallet, profile, tabs, \"Play\" */\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider wins,\na conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`. A game draws its own\ncopy only while nobody took the role. So:\n\n- Next to the base a game hides its own balances and wallet by itself. Do NOT edit a catalog game to\n remove them: it does that itself, and an edit marks it customized (no more catalog updates).\n- `status` — the line of a game's own actions (\"Rolling…\") — stays with each game.\n- A game installed without a base stays complete — nobody owns a role, it draws everything, and the\n host draws its own nav of routes, ☰ and \"Log out\" (those are the host-drawn roles `modes`, `menu`,\n `account`).\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n\n**Layout is the host's job.** A game's overlay fills the screen under the base's small button; a\nscene's own DOM HUD can keep clear of host chrome with the CSS variables on the host root —\n`top: calc(var(--idos-safe-top, 0px) + 8px)`, `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)`\n(voxelcraft's hotbar does this). Both are 0 when nothing is pinned. `hud`-slot panels are wrapped in\n`display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let clicks\nthrough to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference app\n\nThe base + the systems + `board-game` + `idle-rpg`:\n\n- sign-in → straight into the `inLobby` game (else the first game): balances, wallet and profile on\n top, the systems as tabs; \"Play\" lists the two games;\n- \"Play\" → one engine scene mounted at a time; the lobby's tabs stay around an `inLobby` game, a\n full-screen game gets the button back;\n- the games hide their own balances and wallet (the lobby has them); their status lines stay theirs;\n- currency changed in one mode is immediately visible in the others and in the lobby; levelling a hero\n in Idle RPG shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
5
5
  "references": []
6
6
  }