@idosgames/mcp 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,15 @@
1
1
  {
2
- "generatedFromCommit": "9f5e03273160b45b33ffed88fb2ed85b9ea74893",
2
+ "generatedFromCommit": "10ad87a09e9b0fb2e84075caea2cd4f183b7b560",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.11.0",
5
- "@idosgames/wallet": "0.2.4",
6
- "@idosgames/module-sdk": "0.1.12",
7
- "@idosgames/react": "0.2.4",
8
- "@idosgames/app-shell": "0.1.17"
4
+ "@idosgames/core": "0.14.0",
5
+ "@idosgames/wallet": "0.2.7",
6
+ "@idosgames/module-sdk": "0.3.0",
7
+ "@idosgames/react": "0.2.7",
8
+ "@idosgames/app-shell": "0.3.0"
9
9
  },
10
10
  "host": {
11
11
  "id": "host-starter",
12
- "fileCount": 16
12
+ "fileCount": 19
13
13
  },
14
14
  "modules": [
15
15
  {
@@ -45,18 +45,27 @@
45
45
  "url": "https://idosgames.com"
46
46
  },
47
47
  "version": "0.1.0",
48
+ "events": {
49
+ "listens": [
50
+ {
51
+ "topic": "idle-rpg:character-upgraded@1",
52
+ "why": "shows an 'Idle RPG hero reached Lv N' news chip in the board HUD (optional — works without idle-rpg)"
53
+ }
54
+ ]
55
+ },
48
56
  "dependencies": {
49
- "@idosgames/core": "0.11.0",
50
- "@idosgames/module-sdk": "0.1.12",
51
- "@idosgames/react": "0.2.4",
52
- "@idosgames/wallet": "0.2.4",
57
+ "@idosgames/core": "0.14.0",
58
+ "@idosgames/module-sdk": "0.3.0",
59
+ "@idosgames/react": "0.2.7",
60
+ "@idosgames/wallet": "0.2.7",
53
61
  "@tanstack/react-query": "5.101.2",
54
62
  "react": "19.2.7",
55
63
  "three": "0.185.1",
56
64
  "viem": "2.55.2",
57
65
  "wagmi": "3.7.2"
58
66
  },
59
- "fileCount": 24
67
+ "fileCount": 27,
68
+ "contentHash": "c33e4df315755b53354de2ca5973cba5c31c05df352606e1481e4354f93c5541"
60
69
  },
61
70
  {
62
71
  "id": "idle-rpg",
@@ -91,18 +100,31 @@
91
100
  "url": "https://idosgames.com"
92
101
  },
93
102
  "version": "0.1.0",
103
+ "events": {
104
+ "emits": [
105
+ {
106
+ "topic": "idle-rpg:character-upgraded@1",
107
+ "when": "a hero's level went up (after a successful client.character.upgradeCharacterLevel)",
108
+ "payload": {
109
+ "characterId": "string",
110
+ "level": "number"
111
+ }
112
+ }
113
+ ]
114
+ },
94
115
  "dependencies": {
95
- "@idosgames/core": "0.11.0",
96
- "@idosgames/module-sdk": "0.1.12",
97
- "@idosgames/react": "0.2.4",
98
- "@idosgames/wallet": "0.2.4",
116
+ "@idosgames/core": "0.14.0",
117
+ "@idosgames/module-sdk": "0.3.0",
118
+ "@idosgames/react": "0.2.7",
119
+ "@idosgames/wallet": "0.2.7",
99
120
  "@tanstack/react-query": "5.101.2",
100
121
  "phaser": "4.2.1",
101
122
  "react": "19.2.7",
102
123
  "viem": "2.55.2",
103
124
  "wagmi": "3.7.2"
104
125
  },
105
- "fileCount": 16
126
+ "fileCount": 19,
127
+ "contentHash": "c395a20365d53fd5aee509d40dfd9dfad5976b822ec12e808e460f0d0cba432d"
106
128
  },
107
129
  {
108
130
  "id": "voxelcraft",
@@ -110,12 +132,17 @@
110
132
  "type": "template",
111
133
  "engine": "three",
112
134
  "genre": "sandbox",
113
- "summary": "Voxel sandbox (Minecraft-like): walk a first-person world, place and break blocks.",
114
- "description": "A first-person voxel sandbox rendered in Three.js. Players explore a procedurally chunked world, place and break blocks, and move with WASD + mouse-look. Covers any 'build / mine / explore a block world' or open-world sandbox request; the largest of the feature modules.",
135
+ "summary": "Voxel sandbox (Minecraft-like): build a world from a picture + description (with or without AI), then walk it, place and break blocks.",
136
+ "description": "A first-person voxel sandbox rendered in Three.js. The 'Create world' screen turns a reference picture and a text description into a world locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \"voxel-world\") — via a compact WorldSpec recipe: terrain shape, biome zones, 14 structure types (castle, village, bridge…), NPC spawn stubs. Worlds autosave to 'My worlds'. Covers any 'build / mine / explore a block world', open-world sandbox or 'generate a world from a picture' request; the largest of the feature modules.",
115
137
  "provides": [
116
138
  "first-person voxel world exploration",
117
139
  "place and break blocks",
118
140
  "procedural chunked terrain",
141
+ "world generation from a picture + description (WorldSpec recipe)",
142
+ "AI world generation via client.ai (feature \"voxel-world\")",
143
+ "AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)",
144
+ "AI recreates landscapes from pictures (terrain sketch, rivers, biomes)",
145
+ "autosave and 'My worlds' (IndexedDB)",
119
146
  "WASD + mouse-look player controller",
120
147
  "3D block rendering (Three.js)"
121
148
  ],
@@ -125,7 +152,9 @@
125
152
  "minecraft",
126
153
  "3d",
127
154
  "first-person",
128
- "three"
155
+ "three",
156
+ "ai",
157
+ "world-generation"
129
158
  ],
130
159
  "media": {
131
160
  "image": "https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png",
@@ -136,12 +165,71 @@
136
165
  "name": "iDos Games",
137
166
  "url": "https://idosgames.com"
138
167
  },
139
- "version": "0.1.0",
168
+ "version": "0.2.0",
140
169
  "dependencies": {
141
- "@idosgames/module-sdk": "0.1.12",
170
+ "@idosgames/core": "0.14.0",
171
+ "@idosgames/module-sdk": "0.3.0",
142
172
  "three": "0.185.1"
143
173
  },
144
- "fileCount": 38
174
+ "fileCount": 62,
175
+ "contentHash": "cbc5bfa59f373c698f6c11c85ad7255f9092fd05dd38117dc6f57e40a7c3f5b8"
176
+ },
177
+ {
178
+ "id": "game-hud",
179
+ "name": "Game HUD",
180
+ "type": "feature",
181
+ "engine": "dom",
182
+ "sharedUi": {
183
+ "provides": [
184
+ "currency-bar",
185
+ "wallet",
186
+ "status",
187
+ "account"
188
+ ]
189
+ },
190
+ "summary": "Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.",
191
+ "description": "The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.",
192
+ "provides": [
193
+ "one shared currency balance bar for every mode",
194
+ "one crypto wallet button (deposit / withdraw) for the whole game",
195
+ "shared status line / toast messages across modes",
196
+ "player ID with copy and a log-out button",
197
+ "consistent HUD when mixing several game templates"
198
+ ],
199
+ "tags": [
200
+ "hud",
201
+ "ui",
202
+ "shared",
203
+ "wallet",
204
+ "currency",
205
+ "status",
206
+ "chrome"
207
+ ],
208
+ "author": {
209
+ "name": "iDos Games",
210
+ "url": "https://idosgames.com"
211
+ },
212
+ "version": "0.1.0",
213
+ "events": {
214
+ "listens": [
215
+ {
216
+ "topic": "host:mode-changed@1",
217
+ "why": "clears the previous mode's status message when the player switches modes"
218
+ }
219
+ ]
220
+ },
221
+ "dependencies": {
222
+ "@idosgames/core": "0.14.0",
223
+ "@idosgames/module-sdk": "0.3.0",
224
+ "@idosgames/react": "0.2.7",
225
+ "@idosgames/wallet": "0.2.7",
226
+ "@tanstack/react-query": "5.101.2",
227
+ "react": "19.2.7",
228
+ "viem": "2.55.2",
229
+ "wagmi": "3.7.2"
230
+ },
231
+ "fileCount": 9,
232
+ "contentHash": "176e3d1a194c398afafe43b05df5283fa0a45acf6a95442b7222bc1ccf52c61a"
145
233
  }
146
234
  ],
147
235
  "skills": [
@@ -149,6 +237,10 @@
149
237
  "name": "acquisition-attribution",
150
238
  "description": "Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core) knows where a player came from, and how playtime reaches the publisher's analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite codes / idos_click tokens / Telegram start_param at launch, delivery with the login request, the deferred install match, the universal idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and retention. Use this whenever the user asks about attribution, UTM tags, ad campaigns, install tracking, \"where did this player come from\", invite links, deep links carrying a referral code, session counting, playtime, DAU or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL or localStorage for campaign or referral parameters."
151
239
  },
240
+ {
241
+ "name": "ai-generation-system",
242
+ "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."
243
+ },
152
244
  {
153
245
  "name": "authentication",
154
246
  "description": "Log players into a game on the iDosGames TypeScript SDK (@idosgames/core) via client.auth (AuthenticationService): guest/device-id login, two-step email registration with a confirmation code, email login, Google/Telegram login, SSO-code login from idosgames.com, wallet login, password reset, auto login on relaunch, session refresh, logout, and client-side email/password validation. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and asks about logging a player in, sessions, registration, email confirmation codes, resending a code, guest accounts, device-id login, Telegram login, Google login, SSO login, wallet login, forgot/reset password, auto-login, isLoggedIn, or otherwise touches client.auth, AuthenticationService, or AuthContext — even if they don't name the module explicitly."
@@ -211,15 +303,19 @@
211
303
  },
212
304
  {
213
305
  "name": "idosgames-compose-modules",
214
- "description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the cross-module event bus, or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module)."
306
+ "description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the shared HUD (game-hud, sharedUi roles, shouldDraw), typed cross-module events (defineTopic/shape), or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module)."
215
307
  },
216
308
  {
217
309
  "name": "idosgames-getting-started",
218
- "description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest."
310
+ "description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as the project grows). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest."
219
311
  },
220
312
  {
221
313
  "name": "idosgames-module-contract",
222
- "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention."
314
+ "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention."
315
+ },
316
+ {
317
+ "name": "idosgames-project-structure",
318
+ "description": "The layout standard for an iDosGames game project (host shell + composable modules) and how it grows without turning into a mess: where a new feature goes (a catalog module, an existing module, or a new module), the folder layout inside a module, module boundaries (no cross-module imports), the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated, idos.modules.lock.json and customized catalog modules, file size, and project documentation (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing src/shared/, moving code between files or folders, adding a system that spans several modules, or writing project documentation."
223
319
  },
224
320
  {
225
321
  "name": "idosgames-title-bootstrap",
@@ -316,6 +412,14 @@
316
412
  {
317
413
  "name": "user-profile",
318
414
  "description": "Work with the player's own account/session state in the iDosGames TS SDK (@idosgames/core) via client.user (UserService): bootstrap the whole per-player cache at login (ClientState — title config + every module's user state), load the raw inventory snapshot (currencies, items, unstackable instances), read usage-time / session stats, change the username, and delete the account. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants login/session bootstrapping, a profile or account screen, usage-time / playtime tracking, username changes, account deletion, raw inventory reads, or otherwise touches client.user, UserService, ClientState, UserState, UserInventoryState, UsageTimeStats, or client.data.user.state — even if they don't name the module explicitly."
415
+ },
416
+ {
417
+ "name": "voxelcraft-worlds",
418
+ "description": "Build VoxelCraft worlds from a recipe (WorldSpec): from up to 4 reference pictures and a text description, with InApp AI (client.ai, title feature \"voxel-world\") or without it, or written by hand / by an agent. The AI can recreate WHATEVER the pictures show — build objects out of coloured blocks (a plane, a car, a ship, a statue: models made of boxes, cylinders, cones, spheres, lines, with mirror symmetry), rebuild landscapes (a hand-drawn terrain sketch, rivers, biome zones) and combine them into scenes. Covers the WorldSpec JSON format, setting a project's start world (src/worlds/startWorld.ts), the \"Create world\" screen and \"My worlds\" autosave, configuring the \"voxel-world\" AI feature for a title (system prompt, vision model, images, limits, price), and the agent actions generateWorld / loadWorldSpec. Use this whenever the user works on the voxelcraft module and wants a world from a picture, an object from a photo built in blocks, a landscape recreated, a generated / custom / themed voxel map, AI world generation, a start world for their game, or touches WorldSpec, SpecGenerator, VoxelModel, rasterizeModel, parseWorldSpec, buildSpecWithoutAI, CreateWorldUI, createClientWorldAI or VOXEL_WORLD_SYSTEM_PROMPT — even if they don't name the module. Also covers sharing worlds through the Workshop (content type \"voxelcraft.world\")."
419
+ },
420
+ {
421
+ "name": "workshop-system",
422
+ "description": "Let players share what they make — maps, levels, worlds, skins, 3D models, any file — through the Workshop of the iDosGames TypeScript SDK (@idosgames/core client.workshop, WorkshopService): configure content types for a title, publish with files and a thumbnail, set access (free, a price in in-game resources, or \"hold these resources to unlock\" — while held or once), browse the catalog with filters and publisher collections, acquire, download and open content, likes, favorites, following authors, reports, official content. Also covers plugging a game into the ready-made `workshop` module via ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever the user wants user-generated content, a level/map sharing screen, selling maps or skins between players, unlocking content for holders of an item, a creator catalog, or touches client.workshop, WorkshopService, publish, acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions, ContentTypes, ctx.content or the workshop module — even if they don't name the module."
319
423
  }
320
424
  ]
321
425
  }
@@ -34,31 +34,44 @@
34
34
  "name": "iDos Games",
35
35
  "url": "https://idosgames.com"
36
36
  },
37
- "version": "0.1.0"
37
+ "version": "0.1.0",
38
+ "events": {
39
+ "listens": [
40
+ {
41
+ "topic": "idle-rpg:character-upgraded@1",
42
+ "why": "shows an 'Idle RPG hero reached Lv N' news chip in the board HUD (optional — works without idle-rpg)"
43
+ }
44
+ ]
45
+ }
38
46
  },
39
47
  "dependencies": {
40
- "@idosgames/core": "0.11.0",
41
- "@idosgames/module-sdk": "0.1.12",
42
- "@idosgames/react": "0.2.4",
43
- "@idosgames/wallet": "0.2.4",
48
+ "@idosgames/core": "0.14.0",
49
+ "@idosgames/module-sdk": "0.3.0",
50
+ "@idosgames/react": "0.2.7",
51
+ "@idosgames/wallet": "0.2.7",
44
52
  "@tanstack/react-query": "5.101.2",
45
53
  "react": "19.2.7",
46
54
  "three": "0.185.1",
47
55
  "viem": "2.55.2",
48
56
  "wagmi": "3.7.2"
49
57
  },
58
+ "contentHash": "c33e4df315755b53354de2ca5973cba5c31c05df352606e1481e4354f93c5541",
50
59
  "files": [
60
+ {
61
+ "path": "chrome.ts",
62
+ "content": "// Which shared chrome the board draws itself, and a tiny store for news from other modules.\n//\n// In a mix with a shared-UI provider (e.g. game-hud) the board hides its wallet button, currency\n// chips and status line — `setup()` asks `ctx.sharedUi.shouldDraw(role)` once and closes over the\n// answer. Stage, tile and cycle chips are the board's own genre UI and always stay. Installed alone,\n// nobody takes a role and the board is the complete game it always was.\n\nexport interface BoardChrome {\n /** The \"💰 Wallet\" button and its dropdown (role \"wallet\"). */\n wallet: boolean;\n /** Currency chips in the board HUD (role \"currency-bar\"). */\n balances: boolean;\n /** The status line under the action panel (role \"status\"). */\n status: boolean;\n}\n\n/** Everything drawn — the standalone board. */\nexport const FULL_CHROME: BoardChrome = {\n wallet: true,\n balances: true,\n status: true,\n};\n\n/**\n * The latest news line from another module (e.g. \"Idle RPG hero reached Lv 5\"). A store, not React\n * state: the event arrives through `ctx.events` in `setup()` — possibly while the board's panel is\n * unmounted because another mode is active — and the panel reads it whenever it shows.\n */\nexport interface NewsStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n push(text: string): void;\n clear(): void;\n}\n\nexport function createNewsStore(): NewsStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n const set = (next: string | null): void => {\n current = next;\n for (const listener of [...listeners]) listener();\n };\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n push: (text) => set(text),\n clear: () => set(null),\n };\n}\n"
63
+ },
51
64
  {
52
65
  "path": "components/AttackPanel.tsx",
53
66
  "content": "import type { ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { useRunAction } from \"../react/useRunAction\";\nimport {\n panelCard,\n panelTitle,\n primaryButton,\n secondaryButton,\n} from \"./panelStyles\";\n\nexport function AttackPanel(): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const { busy, run } = useRunAction();\n\n const pending = board?.Pending;\n const buildings = pending?.TargetBuildingStates ?? [];\n\n const attack = (buildingIndex: number): void => {\n void run(\n `Attack ${buildingIndex < 0 ? \"(auto)\" : `#${buildingIndex}`}`,\n () => client.gameLoop.boardLoopAttack(buildingIndex),\n );\n };\n\n return (\n <div style={panelCard}>\n <div style={panelTitle}>⚔ Attack</div>\n <div style={{ fontSize: 13, opacity: 0.8 }}>\n Target: {pending?.TargetUserID ?? \"bot\"}{\" \"}\n {pending?.TargetHasShield ? \"· 🛡 shielded\" : \"\"}\n </div>\n {buildings.length > 0 ? (\n <div\n style={{ display: \"flex\", flexWrap: \"wrap\", gap: 6, marginTop: 8 }}\n >\n {buildings.map((b) => (\n <button\n key={b.SlotIndex}\n type=\"button\"\n style={secondaryButton}\n disabled={busy}\n onClick={() => attack(b.SlotIndex)}\n >\n #{b.SlotIndex} · Lv {b.Level ?? 0}\n {b.IsDamaged ? \" (dmg)\" : \"\"}\n </button>\n ))}\n </div>\n ) : null}\n <button\n type=\"button\"\n style={{ ...primaryButton, marginTop: 10 }}\n disabled={busy}\n onClick={() => attack(-1)}\n >\n Attack (auto)\n </button>\n </div>\n );\n}\n"
54
67
  },
55
68
  {
56
69
  "path": "components/BoardHud.tsx",
57
- "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useUserState, useBoardState } from \"../react/hooks\";\nimport {\n readBoardConfig,\n resolveStageName,\n resolveTiles,\n} from \"../data/boardConfig\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst chip: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n};\nconst infoChip: CSSProperties = {\n ...chip,\n color: \"#cfe0ff\",\n background: \"#04276bcc\",\n};\n\nexport function BoardHud(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const board = useBoardState();\n const cfg = readBoardConfig(client);\n\n const currencies: Record<string, UserVirtualCurrencyState> =\n state?.InventoryV2?.VirtualCurrencies ?? {};\n const amount = (id?: string): number =>\n id ? (currencies[id]?.Amount ?? 0) : 0;\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n const stageName = resolveStageName(cfg, stage);\n\n return (\n <div style={row}>\n {cfg.RollCurrencyID ? (\n <span style={chip}>🎲 {amount(cfg.RollCurrencyID)}</span>\n ) : null}\n {cfg.SoftCurrencyID ? (\n <span style={chip}>🪙 {amount(cfg.SoftCurrencyID)}</span>\n ) : null}\n {cfg.ShieldCurrencyID ? (\n <span style={chip}>🛡 {amount(cfg.ShieldCurrencyID)}</span>\n ) : null}\n <span style={infoChip}>\n Stage {stage}\n {stageName ? ` · ${stageName}` : \"\"}\n </span>\n <span style={infoChip}>\n Tile {board?.Position ?? 0}/{tileCount > 0 ? tileCount - 1 : 0}\n </span>\n <span style={infoChip}>Cycles {board?.CyclesCompleted ?? 0}</span>\n </div>\n );\n}\n"
70
+ "content": "import {\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useUserState, useBoardState } from \"../react/hooks\";\nimport {\n readBoardConfig,\n resolveStageName,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport type { NewsStore } from \"../chrome\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst chip: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n};\nconst infoChip: CSSProperties = {\n ...chip,\n color: \"#cfe0ff\",\n background: \"#04276bcc\",\n};\nconst newsChip: CSSProperties = {\n ...chip,\n color: \"#fff\",\n background: \"#0d66fecc\",\n border: \"none\",\n cursor: \"pointer\",\n};\n\nconst noSubscribe = (): (() => void) => () => {};\nconst noNews = (): string | null => null;\n\nexport function BoardHud({\n showBalances = true,\n news,\n}: {\n /** Currency chips — hidden when a shared-UI provider draws balances for every mode. */\n showBalances?: boolean;\n /** News from other modules; click the chip to dismiss it. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const board = useBoardState();\n const cfg = readBoardConfig(client);\n const headline = useSyncExternalStore(\n news?.subscribe ?? noSubscribe,\n news?.get ?? noNews,\n );\n\n const currencies: Record<string, UserVirtualCurrencyState> =\n state?.InventoryV2?.VirtualCurrencies ?? {};\n const amount = (id?: string): number =>\n id ? (currencies[id]?.Amount ?? 0) : 0;\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n const stageName = resolveStageName(cfg, stage);\n\n return (\n <div style={row}>\n {showBalances && cfg.RollCurrencyID ? (\n <span style={chip}>🎲 {amount(cfg.RollCurrencyID)}</span>\n ) : null}\n {showBalances && cfg.SoftCurrencyID ? (\n <span style={chip}>🪙 {amount(cfg.SoftCurrencyID)}</span>\n ) : null}\n {showBalances && cfg.ShieldCurrencyID ? (\n <span style={chip}>🛡 {amount(cfg.ShieldCurrencyID)}</span>\n ) : null}\n <span style={infoChip}>\n Stage {stage}\n {stageName ? ` · ${stageName}` : \"\"}\n </span>\n <span style={infoChip}>\n Tile {board?.Position ?? 0}/{tileCount > 0 ? tileCount - 1 : 0}\n </span>\n <span style={infoChip}>Cycles {board?.CyclesCompleted ?? 0}</span>\n {headline ? (\n <button\n type=\"button\"\n style={newsChip}\n onClick={() => news?.clear()}\n title=\"Dismiss\"\n >\n {headline} ✕\n </button>\n ) : null}\n </div>\n );\n}\n"
58
71
  },
59
72
  {
60
73
  "path": "components/BoardRoot.tsx",
61
- "content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { StatusProvider } from \"../react/status\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: 16,\n fontFamily: \"system-ui, sans-serif\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot(): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [walletOpen, setWalletOpen] = useState(false);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.gameLoop.getBoardDefinition();\n await client.gameLoop.getUserBoardState();\n if (!active) return;\n const cfg = readBoardConfig(client);\n const board = client.data.user.state?.GameLoop?.Board;\n const stage = board?.StageLevel ?? 1;\n controller.loadBoard(\n resolveTiles(cfg, stage),\n resolveBuildingCount(cfg, stage),\n );\n controller.setPosition(board?.Position ?? 0);\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n setReady(true);\n })();\n return () => {\n active = false;\n };\n }, [client, controller]);\n\n // Keep city pad heights in sync with building levels as the cache updates.\n useEffect(() => {\n const off = client.on(\"user:anyUpdated\", () => {\n const board = client.data.user.state?.GameLoop?.Board;\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n });\n return off;\n }, [client, controller]);\n\n return (\n <StatusProvider>\n <div style={overlay}>\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n {walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <BoardHud />\n <div style={footer}>\n {ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n <StatusBar />\n </div>\n </div>\n </StatusProvider>\n );\n}\n"
74
+ "content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"../chrome\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: 16,\n fontFamily: \"system-ui, sans-serif\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot({\n chrome = FULL_CHROME,\n news,\n}: {\n /** Which shared chrome to draw here (the rest is drawn by a shared-UI provider such as game-hud). */\n chrome?: BoardChrome;\n /** News from other modules, shown as a chip in the board HUD. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [walletOpen, setWalletOpen] = useState(false);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.gameLoop.getBoardDefinition();\n await client.gameLoop.getUserBoardState();\n if (!active) return;\n const cfg = readBoardConfig(client);\n const board = client.data.user.state?.GameLoop?.Board;\n const stage = board?.StageLevel ?? 1;\n controller.loadBoard(\n resolveTiles(cfg, stage),\n resolveBuildingCount(cfg, stage),\n );\n controller.setPosition(board?.Position ?? 0);\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n setReady(true);\n })();\n return () => {\n active = false;\n };\n }, [client, controller]);\n\n // Keep city pad heights in sync with building levels as the cache updates.\n useEffect(() => {\n const off = client.on(\"user:anyUpdated\", () => {\n const board = client.data.user.state?.GameLoop?.Board;\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n });\n return off;\n }, [client, controller]);\n\n // No StatusProvider of our own: the host wraps every module in ONE root provider, and a nested one\n // would swallow the board's messages — the shared status line (game-hud) would never see them.\n return (\n <div style={overlay}>\n {chrome.wallet && (\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n )}\n {chrome.wallet && walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <BoardHud showBalances={chrome.balances} news={news} />\n <div style={footer}>\n {ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n {chrome.status && <StatusBar />}\n </div>\n </div>\n );\n}\n"
62
75
  },
63
76
  {
64
77
  "path": "components/BuildPanel.tsx",
@@ -108,17 +121,21 @@
108
121
  "path": "env.ts",
109
122
  "content": "// Runtime config the module reads from the page it runs in: the __IDOS_ENV__ global the host's\n// bundler defines (see the host template's vite.config). A module copied into a project runs in\n// that same page, so it is available; when absent (e.g. the classic preview bundler), the default\n// applies.\n//\n// The TITLE is deliberately NOT here. It comes from the host — `client.titleID`, or `ctx.titleId`\n// in a module's setup(). A module that re-derived the title from the URL could disagree with its\n// host, which is how a build ends up reading one title's data and writing another's.\n\ntype IdosEnv = {\n WALLETCONNECT_PROJECT_ID?: string;\n};\n\ndeclare const __IDOS_ENV__: IdosEnv | undefined;\n\nconst env: IdosEnv =\n typeof __IDOS_ENV__ !== \"undefined\" && __IDOS_ENV__ ? __IDOS_ENV__ : {};\n\nexport const ENV_WALLETCONNECT_PROJECT_ID = env.WALLETCONNECT_PROJECT_ID ?? \"\";\n"
110
123
  },
124
+ {
125
+ "path": "events.ts",
126
+ "content": "import { defineTopic, shape } from \"@idosgames/module-sdk\";\n\n// Topics of OTHER modules this module listens to. Each is this module's own copy of the emitter's\n// descriptor, taken from `events.emits` in the module catalog — never an import from that module\n// (modules are published independently). Only the fields read here are listed; the emitter may send\n// more. Every listen is optional: when idle-rpg is not installed these simply never fire.\n\n/** From idle-rpg's catalog entry: a hero was levelled up (`{ characterId, level }`). */\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n"
127
+ },
111
128
  {
112
129
  "path": "game/BoardController.ts",
113
130
  "content": "import * as THREE from \"three\";\nimport type { TileInfo } from \"../data/boardConfig\";\n\nconst TILE_COLORS: Record<string, number> = {\n Chance: 0xf0a020,\n Reward: 0x4caf50,\n Special: 0x9c5cff,\n RandomAction: 0xe8534e,\n Shield: 0x4a90e2,\n};\nconst DEFAULT_TILE_COLOR = 0x6b6b7a;\n\n// Glyphs drawn on top of non-reward tiles so the board reads at a glance.\nconst TILE_SYMBOLS: Record<string, string> = {\n Chance: \"?\",\n Special: \"★\",\n RandomAction: \"✖\",\n Shield: \"✚\",\n};\n\nconst BUILDING_COLORS = [\n 0x6c5ce7, 0x4caf50, 0xe8534e, 0xf0a020, 0x4a90e2, 0x42c9c2,\n];\n\nconst TOKEN_Y = 0.85;\nconst TILE_Y = 0.13;\nconst STEP_MS = 240;\nconst HIGHLIGHT_MS = 850;\nconst BODY_H = 1.0;\nconst DICE_MS = 950;\nconst DICE_BASE_Y = 3.2;\nconst DICE_DROP = 3.4;\n\n// Standard bounce-out easing (0 → 1 with decaying bounces) for the dice toss.\nfunction bounceOut(t: number): number {\n const n1 = 7.5625;\n const d1 = 2.75;\n if (t < 1 / d1) return n1 * t * t;\n if (t < 2 / d1) {\n const u = t - 1.5 / d1;\n return n1 * u * u + 0.75;\n }\n if (t < 2.5 / d1) {\n const u = t - 2.25 / d1;\n return n1 * u * u + 0.9375;\n }\n const u = t - 2.625 / d1;\n return n1 * u * u + 0.984375;\n}\n\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z. Assign pip values so opposite faces sum to 7.\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\n// Resting rotation that brings each value's face to the top (+Y).\nconst REST_ROTATION: Record<number, [number, number, number]> = {\n 1: [-Math.PI / 2, 0, 0],\n 2: [0, 0, Math.PI / 2],\n 3: [0, 0, 0],\n 4: [Math.PI, 0, 0],\n 5: [0, 0, -Math.PI / 2],\n 6: [Math.PI / 2, 0, 0],\n};\n\nconst PIP_LAYOUTS: Record<number, string[]> = {\n 1: [\"C\"],\n 2: [\"TL\", \"BR\"],\n 3: [\"TL\", \"C\", \"BR\"],\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\n};\nconst PIP_COORDS: Record<string, [number, number]> = {\n TL: [0.28, 0.28],\n TR: [0.72, 0.28],\n ML: [0.28, 0.5],\n C: [0.5, 0.5],\n MR: [0.72, 0.5],\n BL: [0.28, 0.72],\n BR: [0.72, 0.72],\n};\n\ninterface Tween {\n from: THREE.Vector3;\n to: THREE.Vector3;\n start: number;\n resolve: () => void;\n}\n\ninterface DiceTween {\n entries: {\n mesh: THREE.Mesh;\n from: THREE.Euler;\n to: THREE.Euler;\n rest: THREE.Euler;\n }[];\n start: number;\n resolve: () => void;\n}\n\ninterface Building {\n body: THREE.Mesh;\n roof: THREE.Mesh;\n}\n\n/**\n * Three.js renderer for the board: a square loop of tiles (colored + glyph-marked by type), the\n * player's city as towers that grow with their level, an animated token, and a pair of pip dice that\n * tumble to the rolled total. Engine-only — it knows nothing about the SDK.\n */\nexport class BoardController {\n private readonly renderer: THREE.WebGLRenderer;\n private readonly scene: THREE.Scene;\n private readonly camera: THREE.PerspectiveCamera;\n private readonly token: THREE.Mesh;\n private readonly tileGroup = new THREE.Group();\n private readonly cityGroup = new THREE.Group();\n private readonly lookAt = new THREE.Vector3(0, 0, 0);\n private readonly diceTextures: THREE.CanvasTexture[] = [];\n private readonly diceMaterials: THREE.MeshStandardMaterial[] = [];\n private readonly dice: THREE.Mesh[] = [];\n private readonly tileDecalTextures = new Map<string, THREE.CanvasTexture>();\n\n private tilePositions: THREE.Vector3[] = [];\n private tileMeshes: THREE.Mesh[] = [];\n private tileColors: THREE.Color[] = [];\n private buildings: Building[] = [];\n private tween: Tween | null = null;\n private diceTween: DiceTween | null = null;\n private highlight: { index: number; start: number } | null = null;\n private frame = 0;\n private disposed = false;\n private running = true;\n private resizeObserver?: ResizeObserver;\n\n private readonly loop = (): void => {\n if (this.disposed) return;\n this.frame = requestAnimationFrame(this.loop);\n\n if (this.tween) {\n const t = Math.min(1, (performance.now() - this.tween.start) / STEP_MS);\n const e = t * t * (3 - 2 * t); // smoothstep\n const { from, to } = this.tween;\n this.token.position.x = from.x + (to.x - from.x) * e;\n this.token.position.z = from.z + (to.z - from.z) * e;\n this.token.position.y = TOKEN_Y + Math.sin(t * Math.PI) * 0.6;\n if (t >= 1) {\n this.token.position.set(to.x, TOKEN_Y, to.z);\n const done = this.tween.resolve;\n this.tween = null;\n done();\n }\n }\n\n this.updateDice();\n this.updateHighlight();\n this.token.rotation.y += 0.02;\n\n // Gentle camera lean: drift the look-at point a fraction toward the token.\n this.lookAt.lerp(\n new THREE.Vector3(\n this.token.position.x * 0.35,\n 0,\n this.token.position.z * 0.35,\n ),\n 0.05,\n );\n this.camera.lookAt(this.lookAt);\n\n this.renderer.render(this.scene, this.camera);\n };\n\n constructor(private readonly host: HTMLElement) {\n this.renderer = new THREE.WebGLRenderer({ antialias: true });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.domElement.style.display = \"block\";\n host.appendChild(this.renderer.domElement);\n\n this.scene = new THREE.Scene();\n this.scene.background = new THREE.Color(0x063d99);\n\n this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);\n this.camera.position.set(0, 24, 19);\n this.camera.lookAt(0, 0, 0);\n\n const ambient = new THREE.AmbientLight(0xffffff, 0.75);\n const dir = new THREE.DirectionalLight(0xffffff, 0.85);\n dir.position.set(12, 22, 8);\n this.scene.add(ambient, dir, this.tileGroup, this.cityGroup);\n\n const tokenMat = new THREE.MeshStandardMaterial({\n color: 0xffd479,\n emissive: 0x6b5a1f,\n });\n this.token = new THREE.Mesh(\n new THREE.SphereGeometry(0.5, 24, 16),\n tokenMat,\n );\n this.token.position.set(0, TOKEN_Y, 0);\n this.scene.add(this.token);\n\n this.buildDice();\n\n this.resize();\n this.resizeObserver = new ResizeObserver(() => {\n this.resize();\n });\n this.resizeObserver.observe(host);\n\n this.loop();\n }\n\n // ---- dice ----\n\n private buildDice(): void {\n const textureByValue: Record<number, THREE.CanvasTexture> = {};\n for (let v = 1; v <= 6; v++) {\n const texture = new THREE.CanvasTexture(this.makePipCanvas(v));\n this.diceTextures.push(texture);\n textureByValue[v] = texture;\n }\n for (const value of FACE_VALUES) {\n this.diceMaterials.push(\n new THREE.MeshStandardMaterial({\n map: textureByValue[value],\n color: 0xffffff,\n }),\n );\n }\n\n const geo = new THREE.BoxGeometry(1.2, 1.2, 1.2);\n for (let i = 0; i < 2; i++) {\n const die = new THREE.Mesh(geo, this.diceMaterials);\n die.position.set(i === 0 ? -1.0 : 1.0, 3.2, 0);\n const rest = REST_ROTATION[3] ?? [0, 0, 0];\n die.rotation.set(rest[0], 0, rest[2]);\n this.scene.add(die);\n this.dice.push(die);\n }\n }\n\n private makePipCanvas(value: number): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = 128;\n canvas.height = 128;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return canvas;\n const s = canvas.width;\n ctx.fillStyle = \"#f6f3ff\";\n ctx.fillRect(0, 0, s, s);\n ctx.strokeStyle = \"#d9d2f0\";\n ctx.lineWidth = 4;\n ctx.strokeRect(4, 4, s - 8, s - 8);\n ctx.fillStyle = \"#052e7e\";\n for (const slot of PIP_LAYOUTS[value] ?? []) {\n const coord = PIP_COORDS[slot];\n if (!coord) continue;\n ctx.beginPath();\n ctx.arc(coord[0] * s, coord[1] * s, s * 0.1, 0, Math.PI * 2);\n ctx.fill();\n }\n return canvas;\n }\n\n private splitTotal(total: number): [number, number] {\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\n const min1 = Math.max(1, clamped - 6);\n const max1 = Math.min(6, clamped - 1);\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\n return [v1, clamped - v1];\n }\n\n /** Tumble both dice and land them showing two faces that sum to the rolled total. */\n rollDice(total: number): Promise<void> {\n const values = this.splitTotal(total);\n const entries = this.dice.map((mesh, i) => {\n const r = REST_ROTATION[values[i] ?? 3] ?? [0, 0, 0];\n const rest = new THREE.Euler(r[0], 0, r[2]);\n const turns = (n: number): number => Math.PI * 2 * n;\n const to = new THREE.Euler(\n rest.x + turns(2 + i),\n rest.y + turns(2),\n rest.z + turns(2 + i),\n );\n return { mesh, from: mesh.rotation.clone(), to, rest };\n });\n return new Promise<void>((resolve) => {\n this.diceTween = { entries, start: performance.now(), resolve };\n });\n }\n\n private updateDice(): void {\n if (!this.diceTween) return;\n const t = Math.min(1, (performance.now() - this.diceTween.start) / DICE_MS);\n const e = 1 - Math.pow(1 - t, 3); // easeOutCubic for the tumble\n const y = DICE_BASE_Y + DICE_DROP * (1 - bounceOut(t)); // drop + bounce for the toss\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.set(\n entry.from.x + (entry.to.x - entry.from.x) * e,\n entry.from.y + (entry.to.y - entry.from.y) * e,\n entry.from.z + (entry.to.z - entry.from.z) * e,\n );\n entry.mesh.position.y = y;\n }\n if (t >= 1) {\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.copy(entry.rest);\n entry.mesh.position.y = DICE_BASE_Y;\n }\n const done = this.diceTween.resolve;\n this.diceTween = null;\n done();\n }\n }\n\n // ---- board ----\n\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.tilePositions = [];\n this.tileMeshes = [];\n this.tileColors = [];\n this.buildings = [];\n\n const count = tiles.length;\n const perSide = Math.max(1, Math.round(count / 4));\n const spacing = 1.4;\n const extent = perSide * spacing;\n const half = extent / 2;\n\n for (const tile of tiles) {\n const pos = this.ringPosition(tile.index, perSide, extent, half);\n const colorHex = TILE_COLORS[tile.type] ?? DEFAULT_TILE_COLOR;\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.1, 0.25, 1.1),\n new THREE.MeshStandardMaterial({ color: colorHex }),\n );\n mesh.position.set(pos.x, TILE_Y, pos.z);\n\n const symbol = TILE_SYMBOLS[tile.type];\n if (symbol) {\n const decal = new THREE.Mesh(\n new THREE.PlaneGeometry(0.7, 0.7),\n new THREE.MeshBasicMaterial({\n map: this.getDecalTexture(tile.type, symbol),\n transparent: true,\n }),\n );\n decal.rotation.x = -Math.PI / 2;\n decal.position.set(0, 0.14, 0);\n mesh.add(decal);\n }\n\n this.tileGroup.add(mesh);\n this.tilePositions[tile.index] = new THREE.Vector3(pos.x, TOKEN_Y, pos.z);\n this.tileMeshes[tile.index] = mesh;\n this.tileColors[tile.index] = new THREE.Color(colorHex);\n }\n\n const startX = -((buildingCount - 1) * 1.7) / 2;\n for (let i = 0; i < buildingCount; i++) {\n const color = BUILDING_COLORS[i % BUILDING_COLORS.length] ?? 0x6c5ce7;\n const body = new THREE.Mesh(\n new THREE.BoxGeometry(0.9, BODY_H, 0.9),\n new THREE.MeshStandardMaterial({ color }),\n );\n const roof = new THREE.Mesh(\n new THREE.BoxGeometry(1.05, 0.22, 1.05),\n new THREE.MeshStandardMaterial({\n color: new THREE.Color(color).multiplyScalar(0.6),\n }),\n );\n body.position.set(startX + i * 1.7, BODY_H / 2, 0);\n roof.position.set(startX + i * 1.7, BODY_H + 0.11, 0);\n this.cityGroup.add(body, roof);\n this.buildings[i] = { body, roof };\n }\n }\n\n private getDecalTexture(type: string, symbol: string): THREE.CanvasTexture {\n const cached = this.tileDecalTextures.get(type);\n if (cached) return cached;\n const canvas = document.createElement(\"canvas\");\n canvas.width = 96;\n canvas.height = 96;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.clearRect(0, 0, 96, 96);\n ctx.fillStyle = \"rgba(20,16,40,0.85)\";\n ctx.font = \"bold 64px system-ui, sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(symbol, 48, 52);\n }\n const texture = new THREE.CanvasTexture(canvas);\n this.tileDecalTextures.set(type, texture);\n return texture;\n }\n\n private ringPosition(\n index: number,\n perSide: number,\n extent: number,\n half: number,\n ): THREE.Vector3 {\n const side = Math.floor(index / perSide) % 4;\n const f = (index % perSide) / perSide;\n let x = 0;\n let z = 0;\n switch (side) {\n case 0:\n x = -half + f * extent;\n z = half;\n break;\n case 1:\n x = half;\n z = half - f * extent;\n break;\n case 2:\n x = half - f * extent;\n z = -half;\n break;\n default:\n x = -half;\n z = -half + f * extent;\n break;\n }\n return new THREE.Vector3(x, 0, z);\n }\n\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs its RAF). */\n setRunning(running: boolean): void {\n const shouldRun = running && !this.disposed;\n if (shouldRun === this.running) return;\n this.running = shouldRun;\n if (shouldRun) this.frame = requestAnimationFrame(this.loop);\n else cancelAnimationFrame(this.frame);\n }\n\n /** Snap the token to a tile with no animation. */\n setPosition(index: number): void {\n const p = this.tilePositions[index];\n if (p) this.token.position.set(p.x, TOKEN_Y, p.z);\n }\n\n /**\n * What the scene currently shows. Read by the module's agent surface (`agent.ts`): a rendered\n * board is a single canvas, so this is the only way the AI Coder's agent can tell where the\n * token stands and whether the board was loaded at all.\n */\n describeScene(): {\n running: boolean;\n tileCount: number;\n buildingCount: number;\n token: { x: number; z: number };\n } {\n return {\n running: this.running,\n tileCount: this.tilePositions.length,\n buildingCount: this.buildings.length,\n token: {\n x: Math.round(this.token.position.x * 100) / 100,\n z: Math.round(this.token.position.z * 100) / 100,\n },\n };\n }\n\n /** Animate the token through the given tile indices, one hop at a time. */\n async moveToken(path: number[]): Promise<void> {\n for (const idx of path) {\n const to = this.tilePositions[idx];\n if (!to) continue;\n await this.tweenTo(to);\n }\n }\n\n /** Pulse a tile (emissive + lift) to mark where the token landed. */\n highlightTile(index: number): void {\n if (this.tileMeshes[index])\n this.highlight = { index, start: performance.now() };\n }\n\n /** Grow the city towers to reflect each building's level. */\n setBuildingLevels(levels: number[]): void {\n for (let i = 0; i < this.buildings.length; i++) {\n const building = this.buildings[i];\n if (!building) continue;\n const level = levels[i] ?? 0;\n const scaleY = 0.5 + level * 0.9;\n const height = BODY_H * scaleY;\n building.body.scale.y = scaleY;\n building.body.position.y = height / 2;\n building.roof.position.y = height + 0.11;\n }\n }\n\n private updateHighlight(): void {\n if (!this.highlight) return;\n const mesh = this.tileMeshes[this.highlight.index];\n const baseColor = this.tileColors[this.highlight.index];\n if (!mesh || !baseColor) {\n this.highlight = null;\n return;\n }\n const mat = mesh.material as THREE.MeshStandardMaterial;\n const t = (performance.now() - this.highlight.start) / HIGHLIGHT_MS;\n if (t >= 1) {\n mat.emissive.setHex(0x000000);\n mat.emissiveIntensity = 1;\n mesh.position.y = TILE_Y;\n this.highlight = null;\n return;\n }\n const pulse = Math.sin(Math.min(1, t) * Math.PI); // 0 → 1 → 0\n mat.emissive.copy(baseColor);\n mat.emissiveIntensity = pulse * 0.9;\n mesh.position.y = TILE_Y + pulse * 0.35;\n }\n\n private tweenTo(to: THREE.Vector3): Promise<void> {\n return new Promise<void>((resolve) => {\n this.tween = {\n from: this.token.position.clone(),\n to: to.clone(),\n start: performance.now(),\n resolve,\n };\n });\n }\n\n private resize(): void {\n const w = this.host.clientWidth || 640;\n const h = this.host.clientHeight || 640;\n this.renderer.setSize(w, h, false);\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n }\n\n private disposeGroup(group: THREE.Group): void {\n group.traverse((obj) => {\n if (obj instanceof THREE.Mesh) {\n obj.geometry.dispose();\n const material = obj.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material.dispose();\n }\n });\n group.clear();\n }\n\n destroy(): void {\n this.disposed = true;\n cancelAnimationFrame(this.frame);\n this.resizeObserver?.disconnect();\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.token.geometry.dispose();\n (this.token.material as THREE.Material).dispose();\n if (this.dice[0]) this.dice[0].geometry.dispose();\n this.diceMaterials.forEach((m) => m.dispose());\n this.diceTextures.forEach((t) => t.dispose());\n this.tileDecalTextures.forEach((t) => t.dispose());\n this.renderer.dispose();\n this.renderer.domElement.remove();\n }\n}\n"
114
131
  },
115
132
  {
116
133
  "path": "index.ts",
117
- "content": "// @idosgames/mod-board-game — the Board Game feature module (Three.js + React overlay) for the host.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// after copying this into src/modules/.\n\nexport { boardGameModule } from \"./module\";\n\nexport { BoardController } from \"./game/BoardController\";\nexport { createBoardScene } from \"./scene\";\nexport { makeBoardRootPanel } from \"./RootPanel\";\nexport { BoardControllerProvider, useBoardController } from \"./react/context\";\nexport { useBoardState } from \"./react/hooks\";\n\nexport { BoardRoot } from \"./components/BoardRoot\";\nexport { BoardHud } from \"./components/BoardHud\";\nexport { InteractionPanel } from \"./components/InteractionPanel\";\nexport { StatusBar } from \"./components/StatusBar\";\nexport { WalletPanel } from \"./components/WalletPanel\";\n\nexport {\n readBoardConfig,\n resolveTiles,\n resolveStageName,\n resolveBuildingCount,\n buildStepPath,\n type BoardConfig,\n type TileInfo,\n} from \"./data/boardConfig\";\n"
134
+ "content": "// @idosgames/mod-board-game — the Board Game feature module (Three.js + React overlay) for the host.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// after copying this into src/modules/.\n\nexport { boardGameModule } from \"./module\";\n\nexport { BoardController } from \"./game/BoardController\";\nexport { createBoardScene } from \"./scene\";\nexport { makeBoardRootPanel } from \"./RootPanel\";\nexport { BoardControllerProvider, useBoardController } from \"./react/context\";\nexport { useBoardState } from \"./react/hooks\";\nexport {\n FULL_CHROME,\n createNewsStore,\n type BoardChrome,\n type NewsStore,\n} from \"./chrome\";\nexport { idleCharacterUpgraded } from \"./events\";\n\nexport { BoardRoot } from \"./components/BoardRoot\";\nexport { BoardHud } from \"./components/BoardHud\";\nexport { InteractionPanel } from \"./components/InteractionPanel\";\nexport { StatusBar } from \"./components/StatusBar\";\nexport { WalletPanel } from \"./components/WalletPanel\";\n\nexport {\n readBoardConfig,\n resolveTiles,\n resolveStageName,\n resolveBuildingCount,\n buildStepPath,\n type BoardConfig,\n type TileInfo,\n} from \"./data/boardConfig\";\n"
118
135
  },
119
136
  {
120
137
  "path": "module.ts",
121
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box),\n });\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" });\n\n // Поверхность для агента. Здесь она НАМЕРЕННО тонкая: игровой интерфейс этого модуля —\n // обычный React в DOM, агент его и так читает деревом и нажимает кнопки как человек.\n // Дублировать эти же действия в actions значило бы завести второй способ играть, который\n // разъедется с настоящим. Из canvas'а агенту не видно только состояние сцены — его и отдаём.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? { mounted: true, scene: controller.describeScene(), ui: \"dom\" }\n : { mounted: false };\n },\n describeActions: {},\n });\n },\n});\n"
138
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\nimport { createNewsStore, type BoardChrome } from \"./chrome\";\nimport { idleCharacterUpgraded } from \"./events\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n\n // Shared chrome: draw our own copy only while no other module (e.g. game-hud) took the role.\n const chrome: BoardChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n\n // Optional listen: news from Idle RPG, if it is installed. Subscribed here, not in a panel —\n // the board's panel is unmounted while the player is in the Idle RPG mode, exactly when it fires.\n const news = createNewsStore();\n ctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`⚔️ Idle RPG hero reached Lv ${level}`),\n );\n\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box, chrome, news),\n });\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" });\n\n // Поверхность для агента. Здесь она НАМЕРЕННО тонкая: игровой интерфейс этого модуля —\n // обычный React в DOM, агент его и так читает деревом и нажимает кнопки как человек.\n // Дублировать эти же действия в actions значило бы завести второй способ играть, который\n // разъедется с настоящим. Из canvas'а агенту не видно только состояние сцены — его и отдаём.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n news: news.get(),\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
122
139
  },
123
140
  {
124
141
  "path": "react/context.tsx",
@@ -138,11 +155,15 @@
138
155
  },
139
156
  {
140
157
  "path": "RootPanel.tsx",
141
- "content": "import {\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { BoardControllerProvider } from \"./react/context\";\nimport { BoardRoot } from \"./components/BoardRoot\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { BoardController } from \"./game/BoardController\";\n\n// The board's UI as a single full-bleed overlay panel. BoardRoot is already a transparent overlay\n// (pointer-events pass through to the canvas except on its own controls), so it drops straight into\n// the host's overlay slot on top of the Three.js scene. The host provides the client + status\n// contexts; this adds the board controller context once the scene has published its controller.\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeBoardRootPanel(\n box: ControllerBox<BoardController>,\n): ComponentType {\n return function BoardRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading board…</div>;\n }\n return (\n <BoardControllerProvider controller={controller}>\n <BoardRoot />\n </BoardControllerProvider>\n );\n };\n}\n\nconst loadingStyle: CSSProperties = {\n position: \"absolute\",\n top: 16,\n left: 16,\n opacity: 0.6,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n};\n"
158
+ "content": "import {\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { BoardControllerProvider } from \"./react/context\";\nimport { BoardRoot } from \"./components/BoardRoot\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { BoardController } from \"./game/BoardController\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"./chrome\";\n\n// The board's UI as a single full-bleed overlay panel. BoardRoot is already a transparent overlay\n// (pointer-events pass through to the canvas except on its own controls), so it drops straight into\n// the host's overlay slot on top of the Three.js scene. The host provides the client + status\n// contexts; this adds the board controller context once the scene has published its controller.\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeBoardRootPanel(\n box: ControllerBox<BoardController>,\n chrome: BoardChrome = FULL_CHROME,\n news?: NewsStore,\n): ComponentType {\n return function BoardRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading board…</div>;\n }\n return (\n <BoardControllerProvider controller={controller}>\n <BoardRoot chrome={chrome} news={news} />\n </BoardControllerProvider>\n );\n };\n}\n\nconst loadingStyle: CSSProperties = {\n position: \"absolute\",\n top: 16,\n left: 16,\n opacity: 0.6,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n};\n"
142
159
  },
143
160
  {
144
161
  "path": "scene.ts",
145
162
  "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Three.js board renderer as a host-driven EngineScene. Created on mount (it appends its\n// own <canvas> to the host element and starts a RAF loop), published into the shared box for the\n// React overlay, and paused/resumed by the Mode Router via activate/suspend.\nexport function createBoardScene(\n box: ControllerBox<BoardController>,\n): EngineScene {\n let controller: BoardController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new BoardController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
163
+ },
164
+ {
165
+ "path": "module.meta.json",
166
+ "content": "{\n \"id\": \"board-game\",\n \"type\": \"template\",\n \"summary\": \"3D tycoon board game: roll dice, move around the board, buy and upgrade city tiles.\",\n \"description\": \"A turn-based property board game rendered in Three.js with a React overlay. Players roll dice, move around a looping board, and buy, sell and upgrade tiles to build a city and earn income. The board layout, tiles and interactions are data-driven, so the theme and economy are easy to re-skin.\",\n \"provides\": [\n \"turn-based dice movement around a looping board\",\n \"buy / sell / upgrade board tiles\",\n \"player-vs-player property economy\",\n \"3D board rendering with a React HUD overlay\",\n \"data-driven board layout and tile configuration\"\n ],\n \"tags\": [\"board\", \"tycoon\", \"3d\", \"turn-based\", \"economy\", \"three\"],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/board-game/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/board-game/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/board-game/demo/\",\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\",\n \"events\": {\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"shows an 'Idle RPG hero reached Lv N' news chip in the board HUD (optional — works without idle-rpg)\"\n }\n ]\n }\n}\n"
146
167
  }
147
168
  ]
148
169
  }
@@ -0,0 +1,99 @@
1
+ {
2
+ "id": "game-hud",
3
+ "meta": {
4
+ "name": "Game HUD",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "sharedUi": {
9
+ "provides": [
10
+ "currency-bar",
11
+ "wallet",
12
+ "status",
13
+ "account"
14
+ ]
15
+ },
16
+ "catalog": {
17
+ "type": "feature",
18
+ "summary": "Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.",
19
+ "description": "The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.",
20
+ "provides": [
21
+ "one shared currency balance bar for every mode",
22
+ "one crypto wallet button (deposit / withdraw) for the whole game",
23
+ "shared status line / toast messages across modes",
24
+ "player ID with copy and a log-out button",
25
+ "consistent HUD when mixing several game templates"
26
+ ],
27
+ "tags": [
28
+ "hud",
29
+ "ui",
30
+ "shared",
31
+ "wallet",
32
+ "currency",
33
+ "status",
34
+ "chrome"
35
+ ],
36
+ "author": {
37
+ "name": "iDos Games",
38
+ "url": "https://idosgames.com"
39
+ },
40
+ "version": "0.1.0",
41
+ "events": {
42
+ "listens": [
43
+ {
44
+ "topic": "host:mode-changed@1",
45
+ "why": "clears the previous mode's status message when the player switches modes"
46
+ }
47
+ ]
48
+ }
49
+ },
50
+ "dependencies": {
51
+ "@idosgames/core": "0.14.0",
52
+ "@idosgames/module-sdk": "0.3.0",
53
+ "@idosgames/react": "0.2.7",
54
+ "@idosgames/wallet": "0.2.7",
55
+ "@tanstack/react-query": "5.101.2",
56
+ "react": "19.2.7",
57
+ "viem": "2.55.2",
58
+ "wagmi": "3.7.2"
59
+ },
60
+ "contentHash": "176e3d1a194c398afafe43b05df5283fa0a45acf6a95442b7222bc1ccf52c61a",
61
+ "files": [
62
+ {
63
+ "path": "AccountChip.tsx",
64
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The player's ID (click to copy) and \"Log out\". This HUD takes the \"account\" role, so the host does\n// not draw its own copy in the bottom-left corner. Logging out goes through the SDK: it emits\n// \"auth:loggedOut\", and the host returns to the login screen and tears the modules down.\n\nexport function AccountChip(): ReactNode {\n const client = useIDosGamesClient();\n const userID = client.auth.context?.userID ?? null;\n const [copied, setCopied] = useState(false);\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n // Clipboard API needs a secure context; the drag-select fallback still works.\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n\n // Shown truncated, but the click always copies the COMPLETE id; hovering shows it in the tooltip.\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n\n return (\n <>\n {userID && (\n <button\n type=\"button\"\n style={idStyle}\n onClick={copy}\n title={`Copy your player ID: ${userID}`}\n >\n {copied ? \"✓ Copied\" : `ID: ${shortID} ⧉`}\n </button>\n )}\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => void client.auth.logout()}\n title=\"Log out\"\n >\n 🚪 Log out\n </button>\n </>\n );\n}\n\nconst buttonStyle: CSSProperties = {\n border: \"none\",\n borderRadius: 12,\n padding: \"8px 14px\",\n fontWeight: 700,\n fontSize: 13,\n color: \"#cfe0ff\",\n background: \"rgba(4,32,90,0.72)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n};\nconst idStyle: CSSProperties = {\n ...buttonStyle,\n fontWeight: 600,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n};\n"
65
+ },
66
+ {
67
+ "path": "CurrencyChips.tsx",
68
+ "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport type { CurrencyDefinitions } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\n\n// The player's virtual-currency balances, one chip each. Labels come from the title's currency\n// configuration (`Currency.VirtualCurrencies[id].DisplayName`), translated through the title's\n// localization when that name is a localization key; a currency without a definition shows its id.\n\nexport function CurrencyChips(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n const balances = state?.InventoryV2?.VirtualCurrencies ?? {};\n const definitions =\n client.data.config.getSection<CurrencyDefinitions>(\"Currency\")\n ?.VirtualCurrencies ?? {};\n\n const label = (id: string): string => {\n const name = definitions[id]?.DisplayName;\n if (!name) return id;\n // DisplayName is either plain text or a localization key — translate only when it IS a key, so\n // a title with MissingKeyMode \"ReturnMarkedKey\" does not show ⟦Gold⟧.\n return client.localization.has(name) ? client.localization.t(name) : name;\n };\n\n const entries = Object.entries(balances);\n if (entries.length === 0) return null;\n\n return (\n <>\n {entries.map(([id, currency]) => (\n <span key={id} style={chipStyle} title={id}>\n {label(id)}: {currency.Amount ?? 0}\n </span>\n ))}\n </>\n );\n}\n\nconst chipStyle: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n whiteSpace: \"nowrap\",\n pointerEvents: \"auto\",\n};\n"
69
+ },
70
+ {
71
+ "path": "GameHud.tsx",
72
+ "content": "import {\n useEffect,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { useStatus } from \"@idosgames/react\";\nimport type { ModeStore } from \"./modeStore\";\nimport { CurrencyChips } from \"./CurrencyChips\";\nimport { WalletButton } from \"./WalletButton\";\nimport { StatusToast } from \"./StatusToast\";\nimport { AccountChip } from \"./AccountChip\";\n\n// The HUD row: balances on the left, wallet + account on the right, the status toast floating under\n// the middle. It renders in the host's \"hud\" slot, which is a flex row across the top of the screen;\n// the host measures that row and moves every mode's overlay below it, so templates never slide under\n// the HUD.\n//\n// Pointer events: the row itself lets clicks through to the game (voxelcraft needs the canvas), only\n// the actual controls take them.\n\n/** Which parts this HUD draws — the shared-UI roles it won (see module.ts). */\nexport interface HudParts {\n currencies: boolean;\n wallet: boolean;\n status: boolean;\n account: boolean;\n}\n\nexport function makeGameHudPanel(\n mode: ModeStore,\n parts: HudParts,\n): ComponentType {\n return function GameHudPanel(): ReactNode {\n return <GameHud mode={mode} parts={parts} />;\n };\n}\n\nexport function GameHud({\n mode,\n parts,\n}: {\n mode: ModeStore;\n parts: HudParts;\n}): ReactNode {\n const current = useSyncExternalStore(mode.subscribe, mode.get);\n const { clear } = useStatus();\n\n // A status message belongs to the mode that wrote it: the board's \"Rolling…\" must not linger over\n // Idle RPG. (Hide parts per mode here too if a mode does not need them — `current` is its id.)\n useEffect(() => {\n clear();\n }, [current, clear]);\n\n return (\n <div style={barStyle} data-mode={current ?? undefined}>\n <div style={sideStyle}>{parts.currencies && <CurrencyChips />}</div>\n <div style={sideStyle}>\n {parts.wallet && <WalletButton />}\n {parts.account && <AccountChip />}\n </div>\n {parts.status && <StatusToast />}\n </div>\n );\n}\n\nconst barStyle: CSSProperties = {\n flex: 1,\n minWidth: 0,\n position: \"relative\",\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"flex-start\",\n gap: 12,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n pointerEvents: \"none\",\n};\nconst sideStyle: CSSProperties = {\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 8,\n minWidth: 0,\n};\n"
73
+ },
74
+ {
75
+ "path": "index.ts",
76
+ "content": "// @idosgames/mod-game-hud — the shared HUD for games made of several modules.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// after copying this into src/modules/.\n\nexport { gameHudModule } from \"./module\";\n\nexport { GameHud, makeGameHudPanel, type HudParts } from \"./GameHud\";\nexport { CurrencyChips } from \"./CurrencyChips\";\nexport { WalletButton } from \"./WalletButton\";\nexport { StatusToast } from \"./StatusToast\";\nexport { AccountChip } from \"./AccountChip\";\nexport { createModeStore, type ModeStore } from \"./modeStore\";\n"
77
+ },
78
+ {
79
+ "path": "modeStore.ts",
80
+ "content": "// The active mode, as announced by the host (`host:mode-changed@1`). A store, not React state: the\n// subscription lives in `setup()` for the whole session, the panel reads it with\n// useSyncExternalStore.\n\nexport interface ModeStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n set(mode: string): void;\n}\n\nexport function createModeStore(): ModeStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n set(mode) {\n if (mode === current) return;\n current = mode;\n for (const listener of [...listeners]) listener();\n },\n };\n}\n"
81
+ },
82
+ {
83
+ "path": "module.ts",
84
+ "content": "import { defineModule, hostTopics, type Module } from \"@idosgames/module-sdk\";\nimport { createModeStore } from \"./modeStore\";\nimport { makeGameHudPanel, type HudParts } from \"./GameHud\";\n\n// The Game HUD feature module: no scene, no route — one HUD panel that stays on screen in every mode\n// and draws the chrome every mode shares. It declares every shared-UI role, so templates mixed into\n// the same game ask `ctx.sharedUi.shouldDraw(role)`, get `false` and hide their own copies.\nexport const gameHudModule: Module = defineModule({\n id: \"game-hud\",\n meta: {\n name: \"Game HUD\",\n type: \"app\",\n engine: \"dom\",\n },\n sharedUi: { provides: [\"currency-bar\", \"wallet\", \"status\", \"account\"] },\n setup(ctx) {\n // Draw only the roles this module actually won: if another provider comes first in\n // src/modules.ts and takes, say, \"wallet\", this HUD hides its wallet button like any template.\n const parts: HudParts = {\n currencies: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n account: ctx.sharedUi.shouldDraw(\"account\"),\n };\n\n // Subscribed here, not in the panel: setup() runs before the shell mounts, so even the first\n // activation is seen. The panel reads the current mode from the store.\n const mode = createModeStore();\n ctx.events.on(hostTopics.modeChanged, ({ to }) => mode.set(to));\n\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: makeGameHudPanel(mode, parts),\n });\n\n ctx.exposeToAgent({\n state: () => ({ mode: mode.get(), draws: parts }),\n describeActions: {},\n });\n },\n});\n"
85
+ },
86
+ {
87
+ "path": "StatusToast.tsx",
88
+ "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"@idosgames/react\";\n\n// The one status line for the whole game: whatever any module writes with `useStatus().setStatus`\n// lands here, because the host wraps every module in a single root StatusProvider. Floats under the\n// HUD (absolute), so a message appearing does not push the modes' layout down. Click to dismiss; the\n// HUD also clears it on every mode switch.\n\nconst colors: Record<string, string> = {\n info: \"#a9c6ff\",\n success: \"#5ad19a\",\n error: \"#ff6b6b\",\n};\n\nexport function StatusToast(): ReactNode {\n const { message, clear } = useStatus();\n if (!message) return null;\n return (\n <button\n type=\"button\"\n style={{ ...toastStyle, color: colors[message.kind] ?? \"#fff\" }}\n onClick={clear}\n title=\"Dismiss\"\n >\n {message.kind === \"error\" ? \"⚠ \" : message.kind === \"success\" ? \"✓ \" : \"\"}\n {message.text}\n </button>\n );\n}\n\nconst toastStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n maxWidth: \"min(560px, 80vw)\",\n border: \"none\",\n borderRadius: 12,\n padding: \"6px 14px\",\n fontSize: 13,\n fontWeight: 600,\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.85)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n};\n"
89
+ },
90
+ {
91
+ "path": "WalletButton.tsx",
92
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { LazyWalletPanel } from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The one wallet entry for the whole game (deposit/withdraw), opened as a dropdown under the button.\n// The dropdown is absolutely positioned, so opening it does not change the HUD's height — the host\n// measures that height to place every mode below it.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls Reown\n// AppKit into the startup graph and blanks the preview for web3 titles. The wallet config is memoised\n// per project id, so a wallet connected on the sign-in screen is already connected here.\n\nexport function WalletButton(): ReactNode {\n const client = useIDosGamesClient();\n const [open, setOpen] = useState(false);\n\n return (\n <div style={anchorStyle}>\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => setOpen((value) => !value)}\n >\n 💰 Wallet\n </button>\n {open && (\n <div style={dropdownStyle}>\n <LazyWalletPanel\n client={client}\n appName=\"iDosGames\"\n onClose={() => setOpen(false)}\n />\n </div>\n )}\n </div>\n );\n}\n\nconst anchorStyle: CSSProperties = {\n position: \"relative\",\n pointerEvents: \"auto\",\n};\nconst buttonStyle: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst dropdownStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n right: 0,\n zIndex: 5,\n};\n"
93
+ },
94
+ {
95
+ "path": "module.meta.json",
96
+ "content": "{\n \"id\": \"game-hud\",\n \"type\": \"feature\",\n \"summary\": \"Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.\",\n \"description\": \"The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.\",\n \"provides\": [\n \"one shared currency balance bar for every mode\",\n \"one crypto wallet button (deposit / withdraw) for the whole game\",\n \"shared status line / toast messages across modes\",\n \"player ID with copy and a log-out button\",\n \"consistent HUD when mixing several game templates\"\n ],\n \"tags\": [\"hud\", \"ui\", \"shared\", \"wallet\", \"currency\", \"status\", \"chrome\"],\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\",\n \"events\": {\n \"listens\": [\n {\n \"topic\": \"host:mode-changed@1\",\n \"why\": \"clears the previous mode's status message when the player switches modes\"\n }\n ]\n }\n}\n"
97
+ }
98
+ ]
99
+ }