@idosgames/mcp 0.1.10 → 0.1.12

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": "fbf353854ab674684ce8d8dc71eb50f66f4fc858",
2
+ "generatedFromCommit": "0b7cc31dc8e4add49f8b7077653e4399ed9ca00c",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.10.1",
5
- "@idosgames/wallet": "0.2.3",
6
- "@idosgames/module-sdk": "0.1.11",
7
- "@idosgames/react": "0.2.3",
8
- "@idosgames/app-shell": "0.1.16"
4
+ "@idosgames/core": "0.12.0",
5
+ "@idosgames/wallet": "0.2.5",
6
+ "@idosgames/module-sdk": "0.2.0",
7
+ "@idosgames/react": "0.2.5",
8
+ "@idosgames/app-shell": "0.2.0"
9
9
  },
10
10
  "host": {
11
11
  "id": "host-starter",
12
- "fileCount": 15
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.10.1",
50
- "@idosgames/module-sdk": "0.1.11",
51
- "@idosgames/react": "0.2.3",
52
- "@idosgames/wallet": "0.2.3",
57
+ "@idosgames/core": "0.12.0",
58
+ "@idosgames/module-sdk": "0.2.0",
59
+ "@idosgames/react": "0.2.5",
60
+ "@idosgames/wallet": "0.2.5",
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.10.1",
96
- "@idosgames/module-sdk": "0.1.11",
97
- "@idosgames/react": "0.2.3",
98
- "@idosgames/wallet": "0.2.3",
116
+ "@idosgames/core": "0.12.0",
117
+ "@idosgames/module-sdk": "0.2.0",
118
+ "@idosgames/react": "0.2.5",
119
+ "@idosgames/wallet": "0.2.5",
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",
@@ -138,10 +160,68 @@
138
160
  },
139
161
  "version": "0.1.0",
140
162
  "dependencies": {
141
- "@idosgames/module-sdk": "0.1.11",
163
+ "@idosgames/module-sdk": "0.2.0",
142
164
  "three": "0.185.1"
143
165
  },
144
- "fileCount": 38
166
+ "fileCount": 39,
167
+ "contentHash": "4af383a9c61787c8e20a3087c15d194ef743989f0e1583bbb0db6584d9c872d2"
168
+ },
169
+ {
170
+ "id": "game-hud",
171
+ "name": "Game HUD",
172
+ "type": "feature",
173
+ "engine": "dom",
174
+ "sharedUi": {
175
+ "provides": [
176
+ "currency-bar",
177
+ "wallet",
178
+ "status",
179
+ "account"
180
+ ]
181
+ },
182
+ "summary": "Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.",
183
+ "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.",
184
+ "provides": [
185
+ "one shared currency balance bar for every mode",
186
+ "one crypto wallet button (deposit / withdraw) for the whole game",
187
+ "shared status line / toast messages across modes",
188
+ "player ID with copy and a log-out button",
189
+ "consistent HUD when mixing several game templates"
190
+ ],
191
+ "tags": [
192
+ "hud",
193
+ "ui",
194
+ "shared",
195
+ "wallet",
196
+ "currency",
197
+ "status",
198
+ "chrome"
199
+ ],
200
+ "author": {
201
+ "name": "iDos Games",
202
+ "url": "https://idosgames.com"
203
+ },
204
+ "version": "0.1.0",
205
+ "events": {
206
+ "listens": [
207
+ {
208
+ "topic": "host:mode-changed@1",
209
+ "why": "clears the previous mode's status message when the player switches modes"
210
+ }
211
+ ]
212
+ },
213
+ "dependencies": {
214
+ "@idosgames/core": "0.12.0",
215
+ "@idosgames/module-sdk": "0.2.0",
216
+ "@idosgames/react": "0.2.5",
217
+ "@idosgames/wallet": "0.2.5",
218
+ "@tanstack/react-query": "5.101.2",
219
+ "react": "19.2.7",
220
+ "viem": "2.55.2",
221
+ "wagmi": "3.7.2"
222
+ },
223
+ "fileCount": 9,
224
+ "contentHash": "176e3d1a194c398afafe43b05df5283fa0a45acf6a95442b7222bc1ccf52c61a"
145
225
  }
146
226
  ],
147
227
  "skills": [
@@ -161,6 +241,10 @@
161
241
  "name": "character-system",
162
242
  "description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly."
163
243
  },
244
+ {
245
+ "name": "chat-system",
246
+ "description": "Build a player chat in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.chat (ChatService): list the title's channels, poll every room for new messages with one call, send to a channel, open and use 1-on-1 direct conversations, page back through history, mark a room read, join or leave opt-in channels, report a message, and keep a personal ignore list. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a world chat, global chat, server chat, guild or clan chat, private messages, whispers, DMs, an in-game messenger, a chat window or chat bubbles, unread badges for conversations, muting or blocking another player, reporting abuse, or otherwise touches client.chat, ChatService, ChatDefinitions, ChatChannelView, ChatMessageView, ChatCursor, or ChatPollResponse — even if they don't name the module explicitly."
247
+ },
164
248
  {
165
249
  "name": "checkout-system",
166
250
  "description": "Take payment for anything priced in a game on the iDosGames TypeScript SDK: price options (PriceOptions), real-money purchases through Google Play / App Store (client.purchase, PurchaseService), paying with an on-chain currency from the player's wallet (@idosgames/wallet payWithWalletEvm), and choosing what to show per platform (client.checkout, CheckoutService, usePayment). Use this whenever the user works in the iDosGames TS SDK and touches prices, IAP, receipts, store products, \"pay with crypto\", platform gating of payment methods, PriceOption, PaymentProof, SelectedOptionID, or asks why an option is missing or a purchase is refused — even if they don't name the module."
@@ -173,6 +257,10 @@
173
257
  "name": "collection-system",
174
258
  "description": "Build a collection / sticker-album / TCG system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.collection (CollectionService): open collectible packs and pity-driven collection chests, spend \"joker\" wildcards to fill a specific slot, claim set-completion rewards (single + batch) and the collection Grand Prize, and run peer-to-peer collectible trading (send/cancel/accept/decline trade offers, list my/incoming offers). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a sticker album, TCG-style collection/set-completion screen, pack-opening UI, duplicate/pity systems, or player-to-player item trading — or otherwise touches client.collection, CollectionService, CollectionDefinitions, UserCollectionState, or trade offers — even if they don't name the module explicitly."
175
259
  },
260
+ {
261
+ "name": "community-marketing-system",
262
+ "description": "Add a creator reward programme (\"community marketing\") to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.communityMarketing (CommunityMarketingService): show whether the title runs a programme, send the player to the creator portal, read what they earned for posting videos / clips / screenshots to YouTube, TikTok or Instagram, show the per-work breakdown of that number, and claim approved rewards in game currency or crypto. Use this whenever the user is working in the iDosGames TS SDK or its game templates and wants creator rewards, an ambassador or influencer programme, \"get paid for posting about the game\", UGC bounties, view-based payouts, a creator dashboard inside the game, or otherwise touches client.communityMarketing, CommunityMarketingService, CommunityMarketingState, CommunityEarningsResponse or CommunityClaimResponse — even if they don't name the module explicitly."
263
+ },
176
264
  {
177
265
  "name": "coop-event-system",
178
266
  "description": "Build a cooperative / group event system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.coopEvent (CoopEventService): load coop event chain definitions, look up which event in a chain is currently active, load the player's own coop-event state, join or create a matchmade group, spin/contribute toward the group's shared BuildObjects goal, claim a per-member object reward or the group's grand prize, and leave a group. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a co-op event, group event, team event, alliance-style shared-goal feature, spin-to-contribute mechanic, or otherwise touches client.coopEvent, CoopEventService, CoopEventDefinitions, CoopGroupDocument, UserCoopEventState, or CoopSpinResponse — even if they don't name the module explicitly."
@@ -203,15 +291,19 @@
203
291
  },
204
292
  {
205
293
  "name": "idosgames-compose-modules",
206
- "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)."
294
+ "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)."
207
295
  },
208
296
  {
209
297
  "name": "idosgames-getting-started",
210
- "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."
298
+ "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."
211
299
  },
212
300
  {
213
301
  "name": "idosgames-module-contract",
214
- "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."
302
+ "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention."
303
+ },
304
+ {
305
+ "name": "idosgames-project-structure",
306
+ "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."
215
307
  },
216
308
  {
217
309
  "name": "idosgames-title-bootstrap",
@@ -253,6 +345,10 @@
253
345
  "name": "purchase-system",
254
346
  "description": "Sell real-money in-app purchases (IAP) in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.purchase (PurchaseService): load the store product catalog with per-player availability, send a store receipt to the backend for verification, grant the product, restore purchases after a reinstall, and read the player's purchase state (ownership, counters, lifetime spend). Covers Apple App Store and Google Play receipts, consumables / non-consumables / subscriptions, and what happens when the store refunds a purchase. Use whenever the user wants real-money packs, \"remove ads\", a VIP subscription, a restore-purchases button, receipt validation, or touches client.purchase, PurchaseService, IapStore, ValidatePurchase, or IapProductDefinition — even if they don't name the module explicitly."
255
347
  },
348
+ {
349
+ "name": "push-notifications",
350
+ "description": "Add push notifications to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.push (PushService): ask the player for permission from a tap, register the browser's Web Push subscription with the server, draw the right button state, list and remove the player's devices, and ship the service worker that displays a notification. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants push notifications, browser notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a re-engagement or comeback reminder, an energy-refilled or build-finished alert, a service worker, sw.js, VAPID, PushManager, Notification.permission, or otherwise touches client.push, PushService, PushConfigResponse, PushSubscriptionView or PushPermissionState — even if they don't name the module explicitly."
351
+ },
256
352
  {
257
353
  "name": "quest-system",
258
354
  "description": "Build a quest / daily-task system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.quest (QuestService): load quest and cycle definitions, load the player's quest progress state, add progress toward a metric, claim a completed quest's reward, claim a points-track milestone reward, claim a group-completion (grand) reward, and refresh cycles (dailies/ weeklies) forward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest screens, task lists, objective/progress trackers, battle-pass-style points tracks, milestone reward ladders, quest-group completion bonuses, or otherwise touches client.quest, QuestService, QuestDefinitions, UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they don't name the module explicitly."
@@ -275,7 +371,7 @@
275
371
  },
276
372
  {
277
373
  "name": "store-system",
278
- "description": "Build a store / shop system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load storefront and offer (SKU) definitions, load the player's purchase counters, and purchase one or many offers (currency/item packs, bundles, cosmetics) with virtual/item cost. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop/store screen, IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition, or offer purchasing — even if they don't name the module explicitly."
374
+ "description": "Build a store / shop screen in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load the RESOLVED storefront for the current player (rotating daily shops, sections, slots, badges, refresh timers, remaining limits), load the offer catalogue, and purchase one or many offers. Prices can be virtual currency, crypto, a real money store product, a rewarded-video ad credit, or free. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or otherwise touches client.store, StoreService, GetStorefrontResponse, StoreDefinitions or offer purchasing — even if they don't name the module."
279
375
  },
280
376
  {
281
377
  "name": "timed-boost-system",
@@ -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.10.1",
41
- "@idosgames/module-sdk": "0.1.11",
42
- "@idosgames/react": "0.2.3",
43
- "@idosgames/wallet": "0.2.3",
48
+ "@idosgames/core": "0.12.0",
49
+ "@idosgames/module-sdk": "0.2.0",
50
+ "@idosgames/react": "0.2.5",
51
+ "@idosgames/wallet": "0.2.5",
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.12.0",
52
+ "@idosgames/module-sdk": "0.2.0",
53
+ "@idosgames/react": "0.2.5",
54
+ "@idosgames/wallet": "0.2.5",
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
+ }