@idosgames/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/cli.js +204 -0
  4. package/package.json +46 -0
  5. package/registry/host.json +41 -0
  6. package/registry/index.json +215 -0
  7. package/registry/modules/board-game.json +121 -0
  8. package/registry/modules/currency-hud.json +28 -0
  9. package/registry/modules/idle-rpg.json +89 -0
  10. package/registry/modules/voxelcraft.json +163 -0
  11. package/registry/skills/authentication.json +11 -0
  12. package/registry/skills/blockchain-system.json +11 -0
  13. package/registry/skills/character-system.json +11 -0
  14. package/registry/skills/cloud-code.json +6 -0
  15. package/registry/skills/collection-system.json +11 -0
  16. package/registry/skills/coop-event-system.json +11 -0
  17. package/registry/skills/craft-system.json +11 -0
  18. package/registry/skills/currency-system.json +11 -0
  19. package/registry/skills/deal-offer-system.json +11 -0
  20. package/registry/skills/dev-test-loop.json +6 -0
  21. package/registry/skills/game-loop-system.json +11 -0
  22. package/registry/skills/idosgames-compose-modules.json +6 -0
  23. package/registry/skills/idosgames-getting-started.json +6 -0
  24. package/registry/skills/idosgames-module-contract.json +6 -0
  25. package/registry/skills/item-system.json +11 -0
  26. package/registry/skills/leaderboard-system.json +11 -0
  27. package/registry/skills/lootbox-system.json +11 -0
  28. package/registry/skills/marketplace-system.json +11 -0
  29. package/registry/skills/match-system.json +11 -0
  30. package/registry/skills/multiplayer-realtime.json +11 -0
  31. package/registry/skills/premium-system.json +11 -0
  32. package/registry/skills/quest-system.json +11 -0
  33. package/registry/skills/referral-system.json +11 -0
  34. package/registry/skills/reward-system.json +11 -0
  35. package/registry/skills/season-system.json +11 -0
  36. package/registry/skills/social-system.json +6 -0
  37. package/registry/skills/store-system.json +11 -0
  38. package/registry/skills/timed-boost-system.json +11 -0
  39. package/registry/skills/timed-event-system.json +11 -0
  40. package/registry/skills/title-system.json +6 -0
  41. package/registry/skills/user-custom-data.json +11 -0
  42. package/registry/skills/user-profile.json +11 -0
@@ -0,0 +1,121 @@
1
+ {
2
+ "id": "board-game",
3
+ "meta": {
4
+ "name": "Board Game",
5
+ "type": "game",
6
+ "engine": "three",
7
+ "genre": "board"
8
+ },
9
+ "dependencies": {
10
+ "@idosgames/core": "0.1.1",
11
+ "@idosgames/module-sdk": "0.1.0",
12
+ "@idosgames/react": "0.1.0",
13
+ "@idosgames/wallet": "0.1.1",
14
+ "@solana/wallet-adapter-base": "0.9.27",
15
+ "@solana/wallet-adapter-react": "0.15.39",
16
+ "@tanstack/react-query": "5.101.2",
17
+ "react": "19.2.7",
18
+ "react-dom": "19.2.7",
19
+ "three": "0.185.1",
20
+ "viem": "2.55.2",
21
+ "wagmi": "3.7.2"
22
+ },
23
+ "files": [
24
+ {
25
+ "path": "components/AttackPanel.tsx",
26
+ "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"
27
+ },
28
+ {
29
+ "path": "components/BoardHud.tsx",
30
+ "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: \"#241d40cc\",\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: \"#cfc6f0\",\n background: \"#1b1730cc\",\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"
31
+ },
32
+ {
33
+ "path": "components/BoardRoot.tsx",
34
+ "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: \"#6c5ce7\",\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"
35
+ },
36
+ {
37
+ "path": "components/BuildPanel.tsx",
38
+ "content": "import type { ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { useRunAction } from \"../react/useRunAction\";\nimport { panelCard, panelTitle, secondaryButton } from \"./panelStyles\";\n\nexport function BuildPanel(): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const { busy, run } = useRunAction();\n\n const buildings = board?.BuildingStates ?? [];\n if (buildings.length === 0) return null;\n\n const build = (slotIndex: number): void => {\n void run(`Build #${slotIndex}`, () =>\n client.gameLoop.boardLoopBuild(slotIndex),\n );\n };\n\n return (\n <div style={panelCard}>\n <div style={panelTitle}>πŸ— City</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 6 }}>\n {buildings.map((b) => (\n <button\n key={b.SlotIndex}\n type=\"button\"\n style={secondaryButton}\n disabled={busy}\n onClick={() => build(b.SlotIndex)}\n >\n #{b.SlotIndex} Β· Lv {b.Level ?? 0} ⬆\n </button>\n ))}\n </div>\n </div>\n );\n}\n"
39
+ },
40
+ {
41
+ "path": "components/InteractionPanel.tsx",
42
+ "content": "import type { ReactNode } from \"react\";\nimport { useBoardState } from \"../react/hooks\";\nimport { RollControls } from \"./RollControls\";\nimport { BuildPanel } from \"./BuildPanel\";\nimport { SpecialPanel } from \"./SpecialPanel\";\nimport { AttackPanel } from \"./AttackPanel\";\nimport { RaidPanel } from \"./RaidPanel\";\n\n/** Routes the footer UI by the board's pending interaction (restored automatically from the\n * loaded board state). With no pending, the player rolls and manages their city. */\nexport function InteractionPanel(): ReactNode {\n const board = useBoardState();\n const pendingType = board?.Pending?.Type;\n\n if (pendingType === \"SPECIAL\") return <SpecialPanel />;\n if (pendingType === \"ATTACK\") return <AttackPanel />;\n if (pendingType === \"RAID\") return <RaidPanel />;\n\n return (\n <>\n <RollControls />\n <BuildPanel />\n </>\n );\n}\n"
43
+ },
44
+ {
45
+ "path": "components/panelStyles.ts",
46
+ "content": "import type { CSSProperties } from \"react\";\n\nexport const panelCard: CSSProperties = {\n pointerEvents: \"auto\",\n background: \"#1b1730ee\",\n border: \"1px solid #34294f\",\n borderRadius: 12,\n padding: 14,\n color: \"#fff\",\n minWidth: 260,\n maxWidth: 360,\n backdropFilter: \"blur(2px)\",\n};\n\nexport const panelTitle: CSSProperties = { fontWeight: 700, marginBottom: 8 };\n\nexport const primaryButton: CSSProperties = {\n background: \"#6c5ce7\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"8px 16px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\n\nexport const secondaryButton: CSSProperties = {\n background: \"#34294f\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"6px 12px\",\n fontWeight: 600,\n cursor: \"pointer\",\n};\n"
47
+ },
48
+ {
49
+ "path": "components/RaidPanel.tsx",
50
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { useRunAction } from \"../react/useRunAction\";\nimport { readBoardConfig } from \"../data/boardConfig\";\nimport { panelCard, panelTitle, secondaryButton } from \"./panelStyles\";\n\nconst CELL_COUNT = 12;\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(4, 1fr)\",\n gap: 6,\n marginTop: 8,\n};\n\n// Symbol β†’ glyph for revealed cells (1/2/3 = small/medium/big tiers).\nconst GLYPHS: Record<string, string> = { \"1\": \"πŸ”΅\", \"2\": \"🟑\", \"3\": \"πŸ”΄\" };\nfunction glyph(symbol: string | number | null | undefined): string {\n if (symbol == null) return \"βœ“\";\n return GLYPHS[String(symbol)] ?? String(symbol);\n}\n\nexport function RaidPanel(): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const { busy, run } = useRunAction();\n\n const isFast = readBoardConfig(client).RaidMode === \"Fast\";\n const pending = board?.Pending;\n const layout = pending?.RaidLayout ?? [];\n const serverOpened = pending?.OpenedIndices ?? [];\n\n // Fast mode reveals cells locally from the pre-sent layout, then submits the opened set once a\n // 3-of-a-kind is hit. Sequential mode digs one cell per server call.\n const [localOpened, setLocalOpened] = useState<number[]>([]);\n const openedSet = new Set<number>([...serverOpened, ...localOpened]);\n\n const dig = (index: number): void => {\n if (busy || openedSet.has(index)) return;\n\n if (!isFast) {\n void run(`Raid dig #${index}`, () =>\n client.gameLoop.boardLoopRaid(index),\n );\n return;\n }\n\n const next = [...localOpened, index];\n setLocalOpened(next);\n\n const counts = new Map<string, number>();\n for (const idx of [...serverOpened, ...next]) {\n const symbol = layout[idx]?.Symbol;\n if (symbol != null) {\n const key = String(symbol);\n counts.set(key, (counts.get(key) ?? 0) + 1);\n }\n }\n const matchedThree = [...counts.values()].some((v) => v >= 3);\n if (matchedThree) {\n void run(\"Raid\", () =>\n client.gameLoop.boardLoopRaidFast([...serverOpened, ...next]),\n );\n }\n };\n\n return (\n <div style={panelCard}>\n <div style={panelTitle}>⛏ Raid β€” match 3 of a kind</div>\n <div style={grid}>\n {Array.from({ length: CELL_COUNT }, (_unused, i) => {\n const isOpen = openedSet.has(i);\n return (\n <button\n key={i}\n type=\"button\"\n style={{\n ...secondaryButton,\n padding: \"12px 0\",\n fontSize: 18,\n opacity: isOpen ? 0.85 : 1,\n }}\n disabled={busy || isOpen}\n onClick={() => dig(i)}\n >\n {isOpen ? glyph(layout[i]?.Symbol) : \"?\"}\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n"
51
+ },
52
+ {
53
+ "path": "components/RollControls.tsx",
54
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { useStatus } from \"../react/status\";\nimport {\n buildStepPath,\n readBoardConfig,\n resolveTiles,\n} from \"../data/boardConfig\";\n\nconst wrap: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst multRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n};\n\nfunction multButton(active: boolean): CSSProperties {\n return {\n background: active ? \"#ffd479\" : \"#241d40cc\",\n color: active ? \"#241d40\" : \"#cfc6f0\",\n border: \"none\",\n borderRadius: 8,\n padding: \"4px 10px\",\n fontSize: 12,\n fontWeight: 700,\n cursor: \"pointer\",\n };\n}\nconst rollButton: CSSProperties = {\n background: \"#6c5ce7\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 12,\n padding: \"12px 40px\",\n fontSize: 16,\n fontWeight: 700,\n cursor: \"pointer\",\n};\n\nexport function RollControls(): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const board = useBoardState();\n const { setStatus } = useStatus();\n\n const cfg = readBoardConfig(client);\n const multipliers = cfg.AllowedRollMultipliers ?? [1];\n const [mult, setMult] = useState<number>(multipliers[0] ?? 1);\n const [busy, setBusy] = useState(false);\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n\n const roll = async (): Promise<void> => {\n setBusy(true);\n const from = board?.Position ?? 0;\n const result = await client.gameLoop.boardLoopRoll(mult);\n if (result.ok) {\n const data = result.data;\n const path = buildStepPath(\n data.OldPosition ?? from,\n data.NewPosition,\n tileCount,\n );\n await controller.rollDice(data.Steps ?? path.length);\n await controller.moveToken(path);\n controller.highlightTile(data.NewPosition);\n const extra = data.ActionRequired\n ? ` Β· action: ${data.ActionRequired}`\n : data.SpecialModeOffer\n ? \" Β· SPECIAL offered\"\n : \"\";\n setStatus(\n `Rolled ${data.Steps ?? path.length} β†’ tile ${data.NewPosition} (${data.LandedTileType ?? \"?\"})${extra}`,\n \"success\",\n );\n } else {\n setStatus(`Roll failed: ${result.error}`, \"error\");\n }\n setBusy(false);\n };\n\n return (\n <div style={wrap}>\n <div style={multRow}>\n {multipliers.map((m) => (\n <button\n key={m}\n type=\"button\"\n style={multButton(m === mult)}\n onClick={() => setMult(m)}\n >\n Γ—{m}\n </button>\n ))}\n </div>\n <button\n type=\"button\"\n style={{ ...rollButton, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() => void roll()}\n >\n {busy ? \"Rolling…\" : `Roll Γ—${mult}`}\n </button>\n </div>\n );\n}\n"
55
+ },
56
+ {
57
+ "path": "components/SpecialPanel.tsx",
58
+ "content": "import type { ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { useRunAction } from \"../react/useRunAction\";\nimport {\n readSpecialChoices,\n summarizeGrant,\n type SpecialChoice,\n} from \"../data/interactions\";\nimport {\n panelCard,\n panelTitle,\n primaryButton,\n secondaryButton,\n} from \"./panelStyles\";\n\n// Fallback when the offer wasn't stashed (e.g. a pending restored after reload). The live title's\n// \"default\" special mode uses these two choice IDs.\nconst FALLBACK_CHOICES: SpecialChoice[] = [\n { ChoiceID: \"Instant\", Mode: \"Instant\" },\n { ChoiceID: \"Timed\", Mode: \"Timed\" },\n];\n\nexport function SpecialPanel(): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const { busy, run } = useRunAction();\n\n const special = board?.Pending?.Special;\n const isTimedChosen =\n special?.ChosenMode === \"Timed\" || special?.ChosenMode === 1;\n\n const stashed = readSpecialChoices(board);\n const choices = stashed.length > 0 ? stashed : FALLBACK_CHOICES;\n\n const choose = (choiceID: string): void => {\n void run(`Special: ${choiceID}`, () =>\n client.gameLoop.boardSpecialChoose(choiceID),\n );\n };\n const claim = (): void => {\n void run(\"Special claim\", () => client.gameLoop.boardSpecialClaim());\n };\n const claimWithAd = (): void => {\n void run(\"Special claim Γ—ad\", async () => {\n const applied = await client.gameLoop.boardSpecialApplyMultiplier();\n if (!applied.ok) return applied;\n return client.gameLoop.boardSpecialClaim();\n });\n };\n\n if (isTimedChosen) {\n return (\n <div style={panelCard}>\n <div style={panelTitle}>⭐ Special β€” Timed reward</div>\n <div style={{ opacity: 0.75, fontSize: 13, marginBottom: 10 }}>\n Survival window active β€” claim your reward.\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <button\n type=\"button\"\n style={primaryButton}\n disabled={busy}\n onClick={claim}\n >\n Claim\n </button>\n <button\n type=\"button\"\n style={secondaryButton}\n disabled={busy}\n onClick={claimWithAd}\n >\n Claim Γ—ad\n </button>\n </div>\n </div>\n );\n }\n\n return (\n <div style={panelCard}>\n <div style={panelTitle}>⭐ Special tile</div>\n <div style={{ opacity: 0.75, fontSize: 13, marginBottom: 10 }}>\n Pick a reward:\n </div>\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 8 }}>\n {choices.map((choice) => {\n const id = choice.ChoiceID ?? choice.Mode ?? \"?\";\n const reward = summarizeGrant(choice.Reward?.Operation);\n const duration = choice.DurationSeconds\n ? ` Β· ${Math.round(choice.DurationSeconds / 60)} min`\n : \"\";\n return (\n <button\n key={id}\n type=\"button\"\n style={choice.Mode === \"Timed\" ? secondaryButton : primaryButton}\n disabled={busy}\n onClick={() => choose(id)}\n >\n {choice.Mode ?? id}: {reward}\n {duration}\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n"
59
+ },
60
+ {
61
+ "path": "components/StatusBar.tsx",
62
+ "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"../react/status\";\n\nconst colors: Record<string, string> = {\n info: \"#8a82a8\",\n success: \"#5ad19a\",\n error: \"#ff6b6b\",\n};\n\nconst bar: CSSProperties = {\n minHeight: 20,\n fontSize: 13,\n fontFamily: \"system-ui, sans-serif\",\n};\n\nexport function StatusBar(): ReactNode {\n const { message } = useStatus();\n if (!message) return <div style={bar} />;\n return (\n <div style={{ ...bar, color: colors[message.kind] ?? \"#fff\" }}>\n {message.kind === \"error\" ? \"⚠ \" : message.kind === \"success\" ? \"βœ“ \" : \"\"}\n {message.text}\n </div>\n );\n}\n"
63
+ },
64
+ {
65
+ "path": "components/WalletPanel.tsx",
66
+ "content": "import {\n useEffect,\n useMemo,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { parseUnits } from \"viem\";\nimport {\n arbitrum,\n base,\n bsc,\n mainnet,\n optimism,\n polygon,\n polygonAmoy,\n sepolia,\n} from \"viem/chains\";\nimport { useAccount, useConnect, useDisconnect, useSwitchChain } from \"wagmi\";\nimport {\n createEvmWalletConfig,\n IDosGamesWalletProvider,\n useEvmBridge,\n} from \"@idosgames/wallet/react\";\nimport type { BridgeResult } from \"@idosgames/wallet\";\nimport type {\n BlockchainNetworkDefinition,\n CryptoCurrencyDefinition,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { TITLE_ID } from \"../env\";\nimport { ENV_WALLETCONNECT_PROJECT_ID } from \"../env\";\n\n// A curated EVM chain set for the demo β€” enough to cover the networks a title is likely to use.\n// wagmi needs at least one chain up front; the actual network you deposit to comes from the\n// title's blockchain config (the picker below), and we switch the wallet's chain to match.\nconst SUPPORTED_CHAINS = [\n mainnet,\n polygon,\n bsc,\n arbitrum,\n base,\n optimism,\n sepolia,\n polygonAmoy,\n] as const;\n\n// Built once. Set VITE_WALLETCONNECT_PROJECT_ID (get one at cloud.walletconnect.com) to enable\n// MOBILE wallets via the WalletConnect QR/deep-link modal; without it, browser extensions still work.\nconst wagmiConfig = createEvmWalletConfig({\n chains: SUPPORTED_CHAINS,\n walletConnectProjectId: ENV_WALLETCONNECT_PROJECT_ID || undefined,\n appName: \"iDosGames Board\",\n});\n\nconst card: CSSProperties = {\n pointerEvents: \"auto\",\n background: \"#1b1730f5\",\n border: \"1px solid #34294f\",\n borderRadius: 12,\n padding: 16,\n color: \"#fff\",\n width: 320,\n fontFamily: \"system-ui, sans-serif\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n maxHeight: \"80vh\",\n overflow: \"auto\",\n};\nconst title: CSSProperties = { fontWeight: 700, fontSize: 16 };\nconst label: CSSProperties = { fontSize: 12, opacity: 0.7 };\nconst input: CSSProperties = {\n background: \"#241d40\",\n border: \"1px solid #34294f\",\n borderRadius: 8,\n color: \"#fff\",\n padding: \"8px 10px\",\n width: \"100%\",\n boxSizing: \"border-box\",\n};\nconst primary: CSSProperties = {\n background: \"#6c5ce7\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"9px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst ghost: CSSProperties = {\n background: \"#34294f\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"7px 12px\",\n fontWeight: 600,\n cursor: \"pointer\",\n};\nconst row: CSSProperties = { display: \"flex\", gap: 8 };\n\n/** Public entry: the wallet screen wrapped in its own wagmi/react-query provider. */\nexport function WalletPanel({ onClose }: { onClose?: () => void }): ReactNode {\n return (\n <IDosGamesWalletProvider wagmiConfig={wagmiConfig}>\n <WalletPanelInner onClose={onClose} />\n </IDosGamesWalletProvider>\n );\n}\n\nfunction WalletPanelInner({ onClose }: { onClose?: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const bridge = useEvmBridge(client, TITLE_ID);\n const { address, isConnected, chainId } = useAccount();\n const { connect, connectors } = useConnect();\n const { disconnect } = useDisconnect();\n const { switchChainAsync } = useSwitchChain();\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n const [networks, setNetworks] = useState<\n Record<string, BlockchainNetworkDefinition>\n >({});\n const [currencies, setCurrencies] = useState<\n Record<string, CryptoCurrencyDefinition>\n >({});\n const [networkID, setNetworkID] = useState<string>(\"\");\n const [currencyID, setCurrencyID] = useState<string>(\"\");\n const [amount, setAmount] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<BridgeResult<unknown> | null>(null);\n\n // Load blockchain config + on-chain state once; re-render on cache changes for the balance.\n useEffect(() => {\n let active = true;\n void (async () => {\n const defs = await client.blockchain.getDefinitions();\n await client.blockchain.getUserState();\n if (!active || !defs.ok) return;\n const nets = defs.data.Blockchain?.Networks ?? {};\n const evmNets: Record<string, BlockchainNetworkDefinition> = {};\n for (const [id, net] of Object.entries(nets))\n if (net.Type === \"EVM\") evmNets[id] = net;\n setNetworks(evmNets);\n setCurrencies(defs.data.CryptoCurrencies ?? {});\n const firstNet = Object.keys(evmNets)[0] ?? \"\";\n setNetworkID(firstNet);\n })();\n return () => {\n active = false;\n };\n }, [client]);\n\n useEffect(() => client.on(\"user:anyUpdated\", force), [client]);\n\n // Currencies that have an ERC-20 binding on the selected network (a contract we can deposit).\n const eligibleCurrencies = useMemo(() => {\n return Object.entries(currencies).filter(([, def]) =>\n def.Networks?.some(\n (b) => b.NetworkID === networkID && !!b.ContractAddress,\n ),\n );\n }, [currencies, networkID]);\n\n useEffect(() => {\n const first = eligibleCurrencies[0]?.[0] ?? \"\";\n setCurrencyID((prev) =>\n eligibleCurrencies.some(([id]) => id === prev) ? prev : first,\n );\n }, [eligibleCurrencies]);\n\n const network = networkID ? networks[networkID] : undefined;\n const currency = currencyID ? currencies[currencyID] : undefined;\n const binding = currency?.Networks?.find((b) => b.NetworkID === networkID);\n const balance = currencyID\n ? client.data.user.getCryptoCurrencyAmount(currencyID)\n : \"0\";\n\n async function ensureChain(): Promise<boolean> {\n if (!network?.ChainID || chainId === network.ChainID) return true;\n try {\n await switchChainAsync({ chainId: network.ChainID });\n return true;\n } catch {\n setResult({\n ok: false,\n stage: \"approve\",\n error: `Switch your wallet to chain ${network.ChainID} to continue.`,\n });\n return false;\n }\n }\n\n async function runDeposit(): Promise<void> {\n if (!network || !binding?.ContractAddress) return;\n setBusy(true);\n setResult(null);\n if (await ensureChain()) {\n const decimals = binding.Decimals ?? 18;\n let raw: bigint;\n try {\n raw = parseUnits(amount || \"0\", decimals);\n } catch {\n setResult({ ok: false, stage: \"approve\", error: \"Invalid amount.\" });\n setBusy(false);\n return;\n }\n const res = await bridge.depositToken({\n network,\n tokenAddress: binding.ContractAddress as `0x${string}`,\n amount: raw,\n });\n setResult(res);\n if (res.ok) await client.blockchain.getUserState();\n }\n setBusy(false);\n }\n\n async function runWithdraw(): Promise<void> {\n if (!network || !bridge.account) return;\n setBusy(true);\n setResult(null);\n if (await ensureChain()) {\n const res = await bridge.withdrawToken({\n currencyID,\n networkID,\n walletAddress: bridge.account,\n amount: amount || \"0\",\n });\n setResult(res);\n if (res.ok) await client.blockchain.getUserState();\n }\n setBusy(false);\n }\n\n return (\n <div style={card}>\n <div style={{ ...row, justifyContent: \"space-between\" }}>\n <span style={title}>Crypto wallet</span>\n {onClose && (\n <button type=\"button\" style={ghost} onClick={onClose}>\n βœ•\n </button>\n )}\n </div>\n\n {/* Connect */}\n {isConnected ? (\n <div style={row}>\n <span style={{ ...label, flex: 1, alignSelf: \"center\" }}>\n {address?.slice(0, 6)}…{address?.slice(-4)}\n </span>\n <button type=\"button\" style={ghost} onClick={() => disconnect()}>\n Disconnect\n </button>\n </div>\n ) : (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 6 }}>\n <span style={label}>Connect a browser or mobile wallet</span>\n {connectors.map((c) => (\n <button\n key={c.uid}\n type=\"button\"\n style={ghost}\n onClick={() => connect({ connector: c })}\n >\n {c.name}\n </button>\n ))}\n </div>\n )}\n\n {Object.keys(networks).length === 0 ? (\n <span style={label}>No EVM networks configured for this title.</span>\n ) : (\n <>\n <div>\n <div style={label}>Network</div>\n <select\n style={input}\n value={networkID}\n onChange={(e) => setNetworkID(e.target.value)}\n >\n {Object.entries(networks).map(([id, net]) => (\n <option key={id} value={id}>\n {net.DisplayName ?? id}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <div style={label}>Token</div>\n <select\n style={input}\n value={currencyID}\n onChange={(e) => setCurrencyID(e.target.value)}\n >\n {eligibleCurrencies.length === 0 && (\n <option value=\"\">β€” no depositable tokens β€”</option>\n )}\n {eligibleCurrencies.map(([id, def]) => (\n <option key={id} value={id}>\n {def.DisplayName ?? id}\n </option>\n ))}\n </select>\n </div>\n\n <div style={label}>\n In-game balance: <b>{balance}</b> {currencyID}\n </div>\n\n <div>\n <div style={label}>Amount</div>\n <input\n style={input}\n inputMode=\"decimal\"\n placeholder=\"0.0\"\n value={amount}\n onChange={(e) => setAmount(e.target.value)}\n />\n </div>\n\n <div style={row}>\n <button\n type=\"button\"\n style={{ ...primary, flex: 1, opacity: busy ? 0.6 : 1 }}\n disabled={busy || !isConnected || !binding?.ContractAddress}\n onClick={() => void runDeposit()}\n >\n Deposit\n </button>\n <button\n type=\"button\"\n style={{ ...ghost, flex: 1, opacity: busy ? 0.6 : 1 }}\n disabled={busy || !isConnected || !currencyID}\n onClick={() => void runWithdraw()}\n >\n Withdraw\n </button>\n </div>\n </>\n )}\n\n {result && <ResultLine result={result} />}\n </div>\n );\n}\n\nfunction ResultLine({ result }: { result: BridgeResult<unknown> }): ReactNode {\n const style: CSSProperties = {\n fontSize: 12,\n borderRadius: 8,\n padding: \"8px 10px\",\n background: result.ok ? \"#1e3a2a\" : \"#3a1e28\",\n color: result.ok ? \"#8ef0b0\" : \"#f2a0b4\",\n wordBreak: \"break-all\",\n };\n if (result.ok)\n return <div style={style}>βœ“ Done Β· tx {result.onChainTxHash}</div>;\n return (\n <div style={style}>\n βœ• [{result.stage}] {result.error}\n {result.titleTransactionID\n ? ` Β· already debited (tx ${result.titleTransactionID}) β€” retry/confirm, don't re-request`\n : \"\"}\n </div>\n );\n}\n"
67
+ },
68
+ {
69
+ "path": "controller-box.ts",
70
+ "content": "// A one-slot observable that bridges the imperative scene and the React panel. The EngineScene\n// creates the Phaser controller at mount time (it needs the canvas host), which is AFTER the panel\n// first renders; the panel subscribes here and re-renders once the controller is published. This is\n// what lets one controller instance be shared by the scene and the UI without prop-drilling across\n// the imperative/React boundary.\n\nexport interface ControllerBox<T> {\n get(): T | null;\n set(value: T | null): void;\n subscribe(listener: () => void): () => void;\n}\n\nexport function createControllerBox<T>(): ControllerBox<T> {\n let value: T | null = null;\n const listeners = new Set<() => void>();\n return {\n get: () => value,\n set(next) {\n value = next;\n for (const listener of [...listeners]) listener();\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n"
71
+ },
72
+ {
73
+ "path": "data/boardConfig.ts",
74
+ "content": "import type { IDosGamesClient } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `BoardDefinition` config section.\n// Shapes mirror the live backend (titleID URLV9SUP): a 40-tile loop template + per-stage data.\n\nexport interface BoardTileDef {\n Index: number;\n Type: string;\n SpecialModeID?: string;\n ChanceTableID?: string;\n}\n\nexport interface BoardTemplate {\n Tiles?: Record<string, BoardTileDef>;\n}\n\nexport interface StageBuilding {\n SlotIndex: number;\n Name?: string;\n MaxLevel?: number;\n}\n\nexport interface StageDefinition {\n Name?: string;\n BoardTemplateID?: string;\n Buildings?: StageBuilding[];\n}\n\nexport interface BoardConfig {\n RollCurrencyID?: string;\n ShieldCurrencyID?: string;\n SoftCurrencyID?: string;\n RaidMode?: string;\n AllowedRollMultipliers?: number[];\n BoardTemplatesByID?: Record<string, BoardTemplate>;\n StagesByLevel?: Record<string, StageDefinition>;\n}\n\nexport function readBoardConfig(client: IDosGamesClient): BoardConfig {\n return client.data.config.getSection<BoardConfig>(\"BoardDefinition\") ?? {};\n}\n\nexport interface TileInfo {\n index: number;\n type: string;\n}\n\nfunction stageForLevel(\n config: BoardConfig,\n stageLevel: number,\n): StageDefinition | undefined {\n const stages = config.StagesByLevel ?? {};\n return stages[String(stageLevel)] ?? stages[\"1\"] ?? Object.values(stages)[0];\n}\n\n/** The loop tiles (ordered by index) for a stage, resolved through its BoardTemplateID. */\nexport function resolveTiles(\n config: BoardConfig,\n stageLevel: number,\n): TileInfo[] {\n const stage = stageForLevel(config, stageLevel);\n const templates = config.BoardTemplatesByID ?? {};\n const tplID = stage?.BoardTemplateID ?? Object.keys(templates)[0];\n const tpl = tplID ? templates[tplID] : undefined;\n const tiles = tpl?.Tiles ?? {};\n return Object.values(tiles)\n .map((t) => ({ index: t.Index, type: t.Type }))\n .sort((a, b) => a.index - b.index);\n}\n\nexport function resolveStageName(\n config: BoardConfig,\n stageLevel: number,\n): string | undefined {\n return stageForLevel(config, stageLevel)?.Name;\n}\n\nexport function resolveBuildingCount(\n config: BoardConfig,\n stageLevel: number,\n): number {\n return stageForLevel(config, stageLevel)?.Buildings?.length ?? 0;\n}\n\n/** Steps the token visits going from `from` to `to` around an n-tile loop (exclusive of `from`). */\nexport function buildStepPath(from: number, to: number, n: number): number[] {\n if (n <= 0) return [];\n const steps = (((to - from) % n) + n) % n;\n const path: number[] = [];\n for (let i = 1; i <= steps; i++) path.push((from + i) % n);\n return path;\n}\n"
75
+ },
76
+ {
77
+ "path": "data/interactions.ts",
78
+ "content": "import type { BoardLoopState } from \"@idosgames/core\";\n\n// Loose views over the (opaque) pending-interaction payloads, parsed in the template.\n\ninterface GrantEntryLike {\n Type?: string;\n CurrencyID?: string;\n ItemID?: string;\n Amount?: number;\n}\ninterface ResourceOperationLike {\n Grant?: { Standard?: { Entries?: GrantEntryLike[] } };\n}\n\nexport interface SpecialChoice {\n ChoiceID?: string;\n Mode?: string;\n DurationSeconds?: number;\n Reward?: { Operation?: ResourceOperationLike };\n}\n\n/** SPECIAL offer choices stashed on the pending at roll time (empty after a reload). */\nexport function readSpecialChoices(\n board: BoardLoopState | null,\n): SpecialChoice[] {\n const raw = board?.Pending?.Special?.Choices;\n return Array.isArray(raw) ? (raw as SpecialChoice[]) : [];\n}\n\n/** Human-readable summary of a grant operation, e.g. \"+10 Dice\". */\nexport function summarizeGrant(op: ResourceOperationLike | undefined): string {\n const entries = op?.Grant?.Standard?.Entries ?? [];\n const parts = entries.map(\n (e) => `+${e.Amount ?? 0} ${e.CurrencyID ?? e.ItemID ?? \"?\"}`,\n );\n return parts.length > 0 ? parts.join(\", \") : \"reward\";\n}\n"
79
+ },
80
+ {
81
+ "path": "env.ts",
82
+ "content": "// Runtime config the module reads from the page it runs in β€” the same source the host used: URL\n// params and the __IDOS_ENV__ global the host's bundler defines. Present because the wallet bridge\n// needs titleID explicitly and the SDK client doesn't expose it publicly. Missing β†’ safe defaults.\n\ntype IdosEnv = {\n TITLE_ID?: string;\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\nconst launchParams =\n typeof window === \"undefined\"\n ? null\n : new URLSearchParams(window.location.search);\n\nexport const TITLE_ID =\n launchParams?.get(\"titleID\") ?? (env.TITLE_ID || \"URLV9SUP\");\nexport const ENV_WALLETCONNECT_PROJECT_ID = env.WALLETCONNECT_PROJECT_ID ?? \"\";\n"
83
+ },
84
+ {
85
+ "path": "game/BoardController.ts",
86
+ "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(0x16122b);\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 = \"#241d40\";\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 /** 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"
87
+ },
88
+ {
89
+ "path": "index.ts",
90
+ "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"
91
+ },
92
+ {
93
+ "path": "module.ts",
94
+ "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"
95
+ },
96
+ {
97
+ "path": "react/context.tsx",
98
+ "content": "// Shim: the client provider/hook now live in @idosgames/react (shared by every module); the board's\n// own controller context is built with the shared factory. Components keep importing from\n// \"../react/context\" unchanged.\nimport { createControllerContext } from \"@idosgames/react\";\nimport type { BoardController } from \"../game/BoardController\";\n\nexport { IDosGamesProvider, useIDosGamesClient } from \"@idosgames/react\";\n\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\");\n"
99
+ },
100
+ {
101
+ "path": "react/hooks.ts",
102
+ "content": "// Shim: the engine-agnostic hooks now live in @idosgames/react; only the board-specific selector\n// stays here. Components keep importing from \"../react/hooks\" unchanged.\nimport type { BoardLoopState } from \"@idosgames/core\";\nimport { useUserState } from \"@idosgames/react\";\n\nexport { useUserState, useSdkEvent } from \"@idosgames/react\";\n\n/** Live board state (position, stage, buildings, pending interaction). */\nexport function useBoardState(): BoardLoopState | null {\n const state = useUserState();\n return state?.GameLoop?.Board ?? null;\n}\n"
103
+ },
104
+ {
105
+ "path": "react/status.tsx",
106
+ "content": "// Shim: the status provider/hook now live in @idosgames/react (the host provides one StatusProvider\n// at the root). Components keep importing from \"../react/status\" unchanged.\nexport {\n StatusProvider,\n useStatus,\n type StatusKind,\n type StatusMessage,\n} from \"@idosgames/react\";\n"
107
+ },
108
+ {
109
+ "path": "react/useRunAction.ts",
110
+ "content": "import { useCallback, useState } from \"react\";\nimport { useStatus } from \"./status\";\n\nexport interface ActionResult {\n ok: boolean;\n error?: string;\n}\n\n/** Shared \"run a server action, surface the result in the status bar, track busy\" helper. */\nexport function useRunAction(): {\n busy: boolean;\n run: (label: string, action: () => Promise<ActionResult>) => Promise<void>;\n} {\n const { setStatus } = useStatus();\n const [busy, setBusy] = useState(false);\n const run = useCallback(\n async (\n label: string,\n action: () => Promise<ActionResult>,\n ): Promise<void> => {\n setBusy(true);\n const result = await action();\n setStatus(\n result.ok ? `${label} βœ“` : `${label} failed: ${result.error}`,\n result.ok ? \"success\" : \"error\",\n );\n setBusy(false);\n },\n [setStatus],\n );\n return { busy, run };\n}\n"
111
+ },
112
+ {
113
+ "path": "RootPanel.tsx",
114
+ "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"
115
+ },
116
+ {
117
+ "path": "scene.ts",
118
+ "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"
119
+ }
120
+ ]
121
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "currency-hud",
3
+ "meta": {
4
+ "name": "Wallet HUD",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "dependencies": {
9
+ "@idosgames/core": "0.1.1",
10
+ "@idosgames/module-sdk": "0.1.0",
11
+ "@idosgames/react": "0.1.0",
12
+ "react": "19.2.7"
13
+ },
14
+ "files": [
15
+ {
16
+ "path": "CurrencyHud.tsx",
17
+ "content": "import { type CSSProperties, type ReactNode } from \"react\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useUserState } from \"@idosgames/react\";\n\n// A host-level wallet bar: the player's virtual currencies, read from the ONE shared SDK client and\n// kept live via useUserState. Registered with activeOnly:false so it stays pinned across every mode\n// β€” Board, Idle RPG, and even VoxelCraft (a game that itself knows nothing about the SDK). This is\n// the visible proof of cross-module shared state: one cache, every mode reads the same balance.\n\nexport function CurrencyHud(): ReactNode {\n const state = useUserState();\n const currencies: Record<string, UserVirtualCurrencyState> =\n state?.InventoryV2?.VirtualCurrencies ?? {};\n const entries = Object.entries(currencies);\n\n return (\n <div style={bar}>\n <span style={label}>πŸ‘› Wallet</span>\n {entries.length === 0 ? (\n <span style={dim}>β€”</span>\n ) : (\n entries.map(([id, currency]) => (\n <span key={id} style={chip}>\n {id} {currency.Amount ?? 0}\n </span>\n ))\n )}\n </div>\n );\n}\n\nconst bar: CSSProperties = {\n position: \"absolute\",\n top: 10,\n left: \"50%\",\n transform: \"translateX(-50%)\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: \"6px 12px\",\n borderRadius: 999,\n background: \"rgba(20,18,40,0.72)\",\n backdropFilter: \"blur(6px)\",\n border: \"1px solid #2a2342\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n fontSize: 13,\n whiteSpace: \"nowrap\",\n pointerEvents: \"auto\",\n};\nconst label: CSSProperties = { fontWeight: 700, opacity: 0.85 };\nconst chip: CSSProperties = {\n background: \"#241d40\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"3px 10px\",\n fontWeight: 600,\n};\nconst dim: CSSProperties = { opacity: 0.5 };\n"
18
+ },
19
+ {
20
+ "path": "index.ts",
21
+ "content": "// @idosgames/mod-currency-hud β€” a shell module contributing a host-level wallet HUD (no scene, no\n// route). Proves a module can be pure always-on chrome shared across every mode.\n\nexport { currencyHudModule } from \"./module\";\nexport { CurrencyHud } from \"./CurrencyHud\";\n"
22
+ },
23
+ {
24
+ "path": "module.ts",
25
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { CurrencyHud } from \"./CurrencyHud\";\n\n// A \"shell\" module: it contributes no scene and no nav route β€” only always-on chrome. This exercises\n// two contract branches at once: a non-game module (type \"app\", engine \"dom\") and a panel with\n// activeOnly:false that the host keeps visible in every mode.\nexport const currencyHudModule: Module = defineModule({\n id: \"shell-currency\",\n meta: {\n name: \"Wallet HUD\",\n type: \"app\",\n engine: \"dom\",\n },\n setup(ctx) {\n ctx.registerPanel({\n id: \"wallet\",\n slot: \"hud\",\n activeOnly: false,\n component: CurrencyHud,\n });\n },\n});\n"
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,89 @@
1
+ {
2
+ "id": "idle-rpg",
3
+ "meta": {
4
+ "name": "Idle RPG",
5
+ "type": "game",
6
+ "engine": "phaser",
7
+ "genre": "idle-rpg"
8
+ },
9
+ "dependencies": {
10
+ "@idosgames/core": "0.1.1",
11
+ "@idosgames/module-sdk": "0.1.0",
12
+ "@idosgames/react": "0.1.0",
13
+ "@idosgames/wallet": "0.1.1",
14
+ "@solana/wallet-adapter-base": "0.9.27",
15
+ "@solana/wallet-adapter-react": "0.15.39",
16
+ "@tanstack/react-query": "5.101.2",
17
+ "phaser": "4.2.1",
18
+ "react": "19.2.7",
19
+ "react-dom": "19.2.7",
20
+ "viem": "2.55.2",
21
+ "wagmi": "3.7.2"
22
+ },
23
+ "files": [
24
+ {
25
+ "path": "components/CharacterDetail.tsx",
26
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useStatus } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n resolveStats,\n computeStatValue,\n computeUpgradeCost,\n} from \"../data/characterConfig\";\n\nfunction formatStat(value: number): string {\n return Number.isInteger(value) ? String(value) : value.toFixed(2);\n}\n\nconst panel: CSSProperties = {\n border: \"1px solid #34294f\",\n background: \"#1b1730\",\n borderRadius: 12,\n padding: 16,\n minWidth: 240,\n color: \"#fff\",\n};\nconst button: CSSProperties = {\n background: \"#6c5ce7\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"8px 14px\",\n cursor: \"pointer\",\n fontWeight: 600,\n};\nconst statRow: CSSProperties = {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n padding: \"4px 0\",\n};\n\nexport function CharacterDetail({\n characterID,\n}: {\n characterID: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const { setStatus } = useStatus();\n const [busy, setBusy] = useState(false);\n\n if (!characterID)\n return <div style={panel}>Pick a character to see details.</div>;\n\n const config = readCharacterConfig(client);\n const def = config.Definitions?.[characterID];\n const character: CharacterModel | undefined =\n state?.Character?.Characters?.[characterID];\n const availability = resolveAvailability(def, character !== undefined);\n const name = def?.Identity?.DisplayName ?? characterID;\n const subtitle = [def?.Classification?.RarityID, def?.Classification?.ClassID]\n .filter(Boolean)\n .join(\" Β· \");\n\n const run = async (\n label: string,\n action: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n const result = await action();\n setStatus(\n result.ok ? `${label} βœ“` : `${label} failed: ${result.error}`,\n result.ok ? \"success\" : \"error\",\n );\n setBusy(false);\n };\n\n const header = (\n <>\n <div style={{ fontWeight: 700, fontSize: 18 }}>{name}</div>\n {subtitle ? (\n <div style={{ opacity: 0.7, fontSize: 12, marginTop: 2 }}>\n {subtitle}\n </div>\n ) : null}\n </>\n );\n\n // Locked: offer unlock.\n if (availability === \"locked\") {\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.7, margin: \"10px 0\" }}>\n Locked β€” unlock to add it to your roster.\n </div>\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Unlock\", () =>\n client.character.unlockCharacter(characterID),\n )\n }\n >\n Unlock\n </button>\n </div>\n );\n }\n\n // Available (unlocked by default, no model yet): first level-up instantiates it.\n if (availability === \"available\") {\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.7, margin: \"10px 0\" }}>\n Unlocked by default. Level up to instantiate this character.\n </div>\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Level up\", () =>\n client.character.upgradeCharacterLevel(characterID),\n )\n }\n >\n Level up\n </button>\n </div>\n );\n }\n\n // Owned: full controls.\n const statDefs = resolveStats(config, def);\n const statLevels: Record<string, number> = character?.StatLevels ?? {};\n\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.85, margin: \"8px 0 12px\" }}>\n Level {character?.Level ?? 0} Β· Power {character?.Power ?? 0}\n </div>\n\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Level up\", () =>\n client.character.upgradeCharacterLevel(characterID),\n )\n }\n >\n Upgrade level\n </button>\n\n <div style={{ marginTop: 16, fontWeight: 600, opacity: 0.8 }}>Stats</div>\n {statDefs.length === 0 ? (\n <div style={{ opacity: 0.5, fontSize: 13 }}>no stats configured</div>\n ) : (\n statDefs.map((stat) => {\n const level = statLevels[stat.statID] ?? 0;\n const value = computeStatValue(stat, level);\n const atMax = stat.maxLevel != null && level >= stat.maxLevel;\n const cost = computeUpgradeCost(stat, level + 1);\n return (\n <div key={stat.statID} style={statRow}>\n <span>\n {stat.displayName}: <b>{formatStat(value)}</b>\n <span style={{ opacity: 0.5 }}>\n {\" \"}\n Β· Lv {level}\n {stat.maxLevel ? `/${stat.maxLevel}` : \"\"}\n </span>\n </span>\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n {!atMax && cost > 0 ? (\n <span style={{ fontSize: 11, opacity: 0.6 }}>\n {cost} {stat.costCurrency}\n </span>\n ) : null}\n <button\n type=\"button\"\n style={{\n ...button,\n padding: \"2px 10px\",\n background: \"#34294f\",\n opacity: busy || atMax ? 0.5 : 1,\n }}\n disabled={busy || atMax}\n onClick={() =>\n void run(`Upgrade ${stat.displayName}`, () =>\n client.character.upgradeStatLevel(\n characterID,\n stat.statID,\n ),\n )\n }\n >\n {atMax ? \"MAX\" : \"+\"}\n </button>\n </span>\n </div>\n );\n })\n )}\n </div>\n );\n}\n"
27
+ },
28
+ {
29
+ "path": "components/CharacterRoster.tsx",
30
+ "content": "import {\n useEffect,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n type Availability,\n} from \"../data/characterConfig\";\n\nexport interface RosterEntry {\n id: string;\n name: string;\n rarity?: string;\n className?: string;\n availability: Availability;\n level?: number;\n power?: number;\n}\n\n/** Loads character definitions + the player's characters, merges them into a roster, and keeps\n * it live across cache updates. */\nexport function useRoster(): { entries: RosterEntry[]; loading: boolean } {\n const client = useIDosGamesClient();\n const [loading, setLoading] = useState(true);\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.character.getCharacterDefinitions();\n await client.character.getUserCharacters();\n if (active) setLoading(false);\n })();\n const off = client.on(\"user:anyUpdated\", () => {\n force();\n });\n return () => {\n active = false;\n off();\n };\n }, [client]);\n\n const config = readCharacterConfig(client);\n const defs = config.Definitions ?? {};\n const owned = client.data.user.state?.Character?.Characters ?? {};\n const ids = new Set<string>([...Object.keys(defs), ...Object.keys(owned)]);\n\n const entries: RosterEntry[] = [...ids]\n .map((id) => {\n const def = defs[id];\n const character: CharacterModel | undefined = owned[id];\n const entry: RosterEntry = {\n id,\n name: def?.Identity?.DisplayName ?? id,\n rarity: def?.Classification?.RarityID,\n className: def?.Classification?.ClassID,\n availability: resolveAvailability(def, character !== undefined),\n level: character?.Level ?? undefined,\n power: character?.Power ?? undefined,\n };\n return { entry, sortOrder: def?.Identity?.SortOrder ?? 999 };\n })\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((x) => x.entry);\n\n return { entries, loading };\n}\n\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n};\n\nconst statusStyle: Record<Availability, CSSProperties> = {\n owned: {\n color: \"#ffffff\",\n background: \"#241d40\",\n border: \"1px solid #34294f\",\n },\n available: {\n color: \"#cfc6f0\",\n background: \"#1f1a38\",\n border: \"1px solid #4a3d6e\",\n },\n locked: {\n color: \"#8a82a8\",\n background: \"#1b1730\",\n border: \"1px solid #34294f\",\n },\n};\n\nfunction card(availability: Availability, selected: boolean): CSSProperties {\n return {\n textAlign: \"left\",\n borderRadius: 12,\n padding: 12,\n cursor: \"pointer\",\n ...statusStyle[availability],\n ...(selected ? { border: \"2px solid #ffd479\" } : {}),\n };\n}\n\nexport function rosterStatusLine(entry: RosterEntry): string {\n switch (entry.availability) {\n case \"owned\":\n return `Lv ${entry.level ?? 0} Β· Power ${entry.power ?? 0}`;\n case \"available\":\n return \"Available\";\n case \"locked\":\n return \"Locked\";\n }\n}\n\nexport function CharacterRoster({\n selectedID,\n onSelect,\n}: {\n selectedID: string | null;\n onSelect: (entry: RosterEntry) => void;\n}): ReactNode {\n const { entries, loading } = useRoster();\n if (loading) return <div style={{ opacity: 0.6 }}>Loading characters…</div>;\n if (entries.length === 0)\n return <div style={{ opacity: 0.6 }}>No characters configured.</div>;\n\n return (\n <div style={grid}>\n {entries.map((entry) => (\n <button\n key={entry.id}\n type=\"button\"\n style={card(entry.availability, entry.id === selectedID)}\n onClick={() => {\n onSelect(entry);\n }}\n >\n <div style={{ fontWeight: 700, marginBottom: 4 }}>{entry.name}</div>\n <div style={{ fontSize: 11, opacity: 0.7 }}>\n {[entry.rarity, entry.className].filter(Boolean).join(\" Β· \")}\n </div>\n <div style={{ fontSize: 13, marginTop: 6 }}>\n {rosterStatusLine(entry)}\n </div>\n </button>\n ))}\n </div>\n );\n}\n"
31
+ },
32
+ {
33
+ "path": "components/CurrencyHud.tsx",
34
+ "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useUserState } from \"@idosgames/react\";\n\nconst bar: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n padding: \"8px 0\",\n};\nconst chip: CSSProperties = {\n background: \"#241d40\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n};\n\nexport function CurrencyHud(): ReactNode {\n const state = useUserState();\n const currencies = state?.InventoryV2?.VirtualCurrencies ?? {};\n const entries = Object.entries(currencies);\n return (\n <div style={bar}>\n {entries.length === 0 ? (\n <span style={{ opacity: 0.5, fontSize: 13 }}>no currencies</span>\n ) : (\n entries.map(([id, currency]) => (\n <span key={id} style={chip}>\n {id}: {currency.Amount ?? 0}\n </span>\n ))\n )}\n </div>\n );\n}\n"
35
+ },
36
+ {
37
+ "path": "components/EquipmentPanel.tsx",
38
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type {\n EquippedItem,\n EquipSlotPair,\n UnstackableItemInstanceState,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useStatus } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n resolveSlots,\n} from \"../data/characterConfig\";\n\nconst panel: CSSProperties = {\n border: \"1px solid #34294f\",\n background: \"#1b1730\",\n borderRadius: 12,\n padding: 16,\n color: \"#fff\",\n};\nconst slotRow: CSSProperties = {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n padding: \"6px 0\",\n borderBottom: \"1px solid #2a2342\",\n};\nconst smallButton: CSSProperties = {\n background: \"#34294f\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 6,\n padding: \"3px 10px\",\n cursor: \"pointer\",\n fontSize: 12,\n fontWeight: 600,\n};\n\nfunction equippedSlotID(\n item: UnstackableItemInstanceState,\n): string | undefined {\n const slot = item.EquippedSlot as { SlotID?: string } | null | undefined;\n return slot?.SlotID ?? undefined;\n}\n\nexport function EquipmentPanel({\n characterID,\n}: {\n characterID: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const { setStatus } = useStatus();\n const [busy, setBusy] = useState(false);\n const [pickSlot, setPickSlot] = useState<string | null>(null);\n\n if (!characterID) return null;\n\n const config = readCharacterConfig(client);\n const def = config.Definitions?.[characterID];\n const slots = resolveSlots(config, def);\n if (slots.length === 0) return null;\n\n const character = state?.Character?.Characters?.[characterID];\n const owned = resolveAvailability(def, character !== undefined) === \"owned\";\n const level = character?.Level ?? 0;\n const equipment: Record<string, EquippedItem> = character?.Equipment ?? {};\n const equippedCount = Object.keys(equipment).length;\n\n const unstackables: Record<string, UnstackableItemInstanceState> =\n state?.InventoryV2?.UnstackableItems ?? {};\n const freeItems = Object.values(unstackables).filter(\n (item) => equippedSlotID(item) === undefined,\n );\n\n const run = async (\n label: string,\n action: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n const result = await action();\n setStatus(\n result.ok ? `${label} βœ“` : `${label} failed: ${result.error}`,\n result.ok ? \"success\" : \"error\",\n );\n setBusy(false);\n setPickSlot(null);\n };\n\n const equip = (slotID: string, item: UnstackableItemInstanceState): void => {\n const pair: EquipSlotPair = {\n SlotID: slotID,\n ItemID: item.ItemID,\n CatalogID: item.CatalogID ?? undefined,\n };\n void run(`Equip ${item.ItemID} β†’ ${slotID}`, () =>\n client.character.equipItems(characterID, [pair]),\n );\n };\n const unequip = (slotID: string): void => {\n void run(`Unequip ${slotID}`, () =>\n client.character.unequipItems(characterID, [slotID]),\n );\n };\n const unequipAll = (): void => {\n void run(\"Unequip all\", () => client.character.unequipAllCharacters());\n };\n\n return (\n <div style={panel}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n }}\n >\n <div style={{ fontWeight: 700 }}>Equipment</div>\n <button\n type=\"button\"\n style={{\n ...smallButton,\n opacity: busy || equippedCount === 0 ? 0.5 : 1,\n }}\n disabled={busy || equippedCount === 0}\n onClick={unequipAll}\n >\n Unequip all\n </button>\n </div>\n\n {!owned ? (\n <div style={{ opacity: 0.6, fontSize: 12, margin: \"8px 0\" }}>\n Slots from config. Equip becomes available once the character is\n owned.\n </div>\n ) : null}\n\n <div style={{ marginTop: 8 }}>\n {slots.map((slot) => {\n const slotLocked = level < slot.minCharacterLevel;\n const equipped: EquippedItem | undefined = equipment[slot.slotID];\n const hasItem = !!equipped?.ItemInstanceID || !!equipped?.ItemID;\n return (\n <div key={slot.slotID} style={slotRow}>\n <div>\n <div style={{ fontWeight: 600 }}>{slot.slotID}</div>\n {slotLocked ? (\n <div style={{ fontSize: 11, color: \"#ff9f6b\" }}>\n Locked Β· needs Lv {slot.minCharacterLevel}\n </div>\n ) : (\n <div style={{ fontSize: 12, opacity: 0.7 }}>\n {hasItem ? equipped?.ItemID : \"Empty\"}\n </div>\n )}\n </div>\n {!slotLocked && hasItem ? (\n <button\n type=\"button\"\n style={smallButton}\n disabled={busy}\n onClick={() => unequip(slot.slotID)}\n >\n Unequip\n </button>\n ) : null}\n {!slotLocked && !hasItem && owned ? (\n <button\n type=\"button\"\n style={{ ...smallButton, background: \"#6c5ce7\" }}\n disabled={busy}\n onClick={() =>\n setPickSlot(pickSlot === slot.slotID ? null : slot.slotID)\n }\n >\n {pickSlot === slot.slotID ? \"Cancel\" : \"Equip\"}\n </button>\n ) : null}\n </div>\n );\n })}\n </div>\n\n {pickSlot ? (\n <div style={{ marginTop: 10 }}>\n <div style={{ fontSize: 12, opacity: 0.8, marginBottom: 6 }}>\n Choose an item for {pickSlot}:\n </div>\n {freeItems.length === 0 ? (\n <div style={{ opacity: 0.5, fontSize: 12 }}>\n No equippable items in inventory.\n </div>\n ) : (\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 6 }}>\n {freeItems.map((item) => (\n <button\n key={item.ItemInstanceID}\n type=\"button\"\n style={smallButton}\n disabled={busy}\n onClick={() => equip(pickSlot, item)}\n >\n {item.ItemID} Β· Lv {Math.max(1, item.Level ?? 1)}\n </button>\n ))}\n </div>\n )}\n </div>\n ) : null}\n </div>\n );\n}\n"
39
+ },
40
+ {
41
+ "path": "components/StatusBar.tsx",
42
+ "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"@idosgames/react\";\n\nconst colors: Record<string, string> = {\n info: \"#8a82a8\",\n success: \"#5ad19a\",\n error: \"#ff6b6b\",\n};\n\nconst bar: CSSProperties = {\n marginTop: 12,\n minHeight: 20,\n fontSize: 13,\n fontFamily: \"system-ui, sans-serif\",\n};\n\nexport function StatusBar(): ReactNode {\n const { message } = useStatus();\n if (!message) return <div style={bar} />;\n return (\n <div style={{ ...bar, color: colors[message.kind] ?? \"#fff\" }}>\n {message.kind === \"error\" ? \"⚠ \" : message.kind === \"success\" ? \"βœ“ \" : \"\"}\n {message.text}\n </div>\n );\n}\n"
43
+ },
44
+ {
45
+ "path": "components/WalletPanel.tsx",
46
+ "content": "import {\n useEffect,\n useMemo,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { parseUnits } from \"viem\";\nimport {\n arbitrum,\n base,\n bsc,\n mainnet,\n optimism,\n polygon,\n polygonAmoy,\n sepolia,\n} from \"viem/chains\";\nimport { useAccount, useConnect, useDisconnect, useSwitchChain } from \"wagmi\";\nimport {\n createEvmWalletConfig,\n IDosGamesWalletProvider,\n useEvmBridge,\n} from \"@idosgames/wallet/react\";\nimport type { BridgeResult } from \"@idosgames/wallet\";\nimport type {\n BlockchainNetworkDefinition,\n CryptoCurrencyDefinition,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { TITLE_ID } from \"../env\";\nimport { ENV_WALLETCONNECT_PROJECT_ID } from \"../env\";\n\n// A curated EVM chain set for the demo β€” enough to cover the networks a title is likely to use.\n// wagmi needs at least one chain up front; the actual network you deposit to comes from the\n// title's blockchain config (the picker below), and we switch the wallet's chain to match.\nconst SUPPORTED_CHAINS = [\n mainnet,\n polygon,\n bsc,\n arbitrum,\n base,\n optimism,\n sepolia,\n polygonAmoy,\n] as const;\n\n// Built once. Set VITE_WALLETCONNECT_PROJECT_ID (get one at cloud.walletconnect.com) to enable\n// MOBILE wallets via the WalletConnect QR/deep-link modal; without it, browser extensions still work.\nconst wagmiConfig = createEvmWalletConfig({\n chains: SUPPORTED_CHAINS,\n walletConnectProjectId: ENV_WALLETCONNECT_PROJECT_ID || undefined,\n appName: \"iDosGames Idle RPG\",\n});\n\nconst card: CSSProperties = {\n background: \"#181334\",\n border: \"1px solid #2c2650\",\n borderRadius: 12,\n padding: 16,\n color: \"#fff\",\n width: \"100%\",\n boxSizing: \"border-box\",\n fontFamily: \"system-ui, sans-serif\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n};\nconst title: CSSProperties = { fontWeight: 700, fontSize: 16 };\nconst label: CSSProperties = { fontSize: 12, opacity: 0.7 };\nconst input: CSSProperties = {\n background: \"#241d40\",\n border: \"1px solid #34294f\",\n borderRadius: 8,\n color: \"#fff\",\n padding: \"8px 10px\",\n width: \"100%\",\n boxSizing: \"border-box\",\n};\nconst primary: CSSProperties = {\n background: \"#6c5ce7\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"9px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst ghost: CSSProperties = {\n background: \"#34294f\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"7px 12px\",\n fontWeight: 600,\n cursor: \"pointer\",\n};\nconst row: CSSProperties = { display: \"flex\", gap: 8 };\n\n/** Public entry: the wallet screen wrapped in its own wagmi/react-query provider. */\nexport function WalletPanel(): ReactNode {\n return (\n <IDosGamesWalletProvider wagmiConfig={wagmiConfig}>\n <WalletPanelInner />\n </IDosGamesWalletProvider>\n );\n}\n\nfunction WalletPanelInner(): ReactNode {\n const client = useIDosGamesClient();\n const bridge = useEvmBridge(client, TITLE_ID);\n const { address, isConnected, chainId } = useAccount();\n const { connect, connectors } = useConnect();\n const { disconnect } = useDisconnect();\n const { switchChainAsync } = useSwitchChain();\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n const [networks, setNetworks] = useState<\n Record<string, BlockchainNetworkDefinition>\n >({});\n const [currencies, setCurrencies] = useState<\n Record<string, CryptoCurrencyDefinition>\n >({});\n const [networkID, setNetworkID] = useState<string>(\"\");\n const [currencyID, setCurrencyID] = useState<string>(\"\");\n const [amount, setAmount] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<BridgeResult<unknown> | null>(null);\n\n // Load blockchain config + on-chain state once; re-render on cache changes for the balance.\n useEffect(() => {\n let active = true;\n void (async () => {\n const defs = await client.blockchain.getDefinitions();\n await client.blockchain.getUserState();\n if (!active || !defs.ok) return;\n const nets = defs.data.Blockchain?.Networks ?? {};\n const evmNets: Record<string, BlockchainNetworkDefinition> = {};\n for (const [id, net] of Object.entries(nets))\n if (net.Type === \"EVM\") evmNets[id] = net;\n setNetworks(evmNets);\n setCurrencies(defs.data.CryptoCurrencies ?? {});\n const firstNet = Object.keys(evmNets)[0] ?? \"\";\n setNetworkID(firstNet);\n })();\n return () => {\n active = false;\n };\n }, [client]);\n\n useEffect(() => client.on(\"user:anyUpdated\", force), [client]);\n\n // Currencies that have an ERC-20 binding on the selected network (a contract we can deposit).\n const eligibleCurrencies = useMemo(() => {\n return Object.entries(currencies).filter(([, def]) =>\n def.Networks?.some(\n (b) => b.NetworkID === networkID && !!b.ContractAddress,\n ),\n );\n }, [currencies, networkID]);\n\n useEffect(() => {\n const first = eligibleCurrencies[0]?.[0] ?? \"\";\n setCurrencyID((prev) =>\n eligibleCurrencies.some(([id]) => id === prev) ? prev : first,\n );\n }, [eligibleCurrencies]);\n\n const network = networkID ? networks[networkID] : undefined;\n const currency = currencyID ? currencies[currencyID] : undefined;\n const binding = currency?.Networks?.find((b) => b.NetworkID === networkID);\n const balance = currencyID\n ? client.data.user.getCryptoCurrencyAmount(currencyID)\n : \"0\";\n\n async function ensureChain(): Promise<boolean> {\n if (!network?.ChainID || chainId === network.ChainID) return true;\n try {\n await switchChainAsync({ chainId: network.ChainID });\n return true;\n } catch {\n setResult({\n ok: false,\n stage: \"approve\",\n error: `Switch your wallet to chain ${network.ChainID} to continue.`,\n });\n return false;\n }\n }\n\n async function runDeposit(): Promise<void> {\n if (!network || !binding?.ContractAddress) return;\n setBusy(true);\n setResult(null);\n if (await ensureChain()) {\n const decimals = binding.Decimals ?? 18;\n let raw: bigint;\n try {\n raw = parseUnits(amount || \"0\", decimals);\n } catch {\n setResult({ ok: false, stage: \"approve\", error: \"Invalid amount.\" });\n setBusy(false);\n return;\n }\n const res = await bridge.depositToken({\n network,\n tokenAddress: binding.ContractAddress as `0x${string}`,\n amount: raw,\n });\n setResult(res);\n if (res.ok) await client.blockchain.getUserState();\n }\n setBusy(false);\n }\n\n async function runWithdraw(): Promise<void> {\n if (!network || !bridge.account) return;\n setBusy(true);\n setResult(null);\n if (await ensureChain()) {\n const res = await bridge.withdrawToken({\n currencyID,\n networkID,\n walletAddress: bridge.account,\n amount: amount || \"0\",\n });\n setResult(res);\n if (res.ok) await client.blockchain.getUserState();\n }\n setBusy(false);\n }\n\n return (\n <div style={card}>\n <span style={title}>Crypto wallet</span>\n\n {/* Connect */}\n {isConnected ? (\n <div style={row}>\n <span style={{ ...label, flex: 1, alignSelf: \"center\" }}>\n {address?.slice(0, 6)}…{address?.slice(-4)}\n </span>\n <button type=\"button\" style={ghost} onClick={() => disconnect()}>\n Disconnect\n </button>\n </div>\n ) : (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 6 }}>\n <span style={label}>Connect a browser or mobile wallet</span>\n {connectors.map((c) => (\n <button\n key={c.uid}\n type=\"button\"\n style={ghost}\n onClick={() => connect({ connector: c })}\n >\n {c.name}\n </button>\n ))}\n </div>\n )}\n\n {Object.keys(networks).length === 0 ? (\n <span style={label}>No EVM networks configured for this title.</span>\n ) : (\n <>\n <div>\n <div style={label}>Network</div>\n <select\n style={input}\n value={networkID}\n onChange={(e) => setNetworkID(e.target.value)}\n >\n {Object.entries(networks).map(([id, net]) => (\n <option key={id} value={id}>\n {net.DisplayName ?? id}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <div style={label}>Token</div>\n <select\n style={input}\n value={currencyID}\n onChange={(e) => setCurrencyID(e.target.value)}\n >\n {eligibleCurrencies.length === 0 && (\n <option value=\"\">β€” no depositable tokens β€”</option>\n )}\n {eligibleCurrencies.map(([id, def]) => (\n <option key={id} value={id}>\n {def.DisplayName ?? id}\n </option>\n ))}\n </select>\n </div>\n\n <div style={label}>\n In-game balance: <b>{balance}</b> {currencyID}\n </div>\n\n <div>\n <div style={label}>Amount</div>\n <input\n style={input}\n inputMode=\"decimal\"\n placeholder=\"0.0\"\n value={amount}\n onChange={(e) => setAmount(e.target.value)}\n />\n </div>\n\n <div style={row}>\n <button\n type=\"button\"\n style={{ ...primary, flex: 1, opacity: busy ? 0.6 : 1 }}\n disabled={busy || !isConnected || !binding?.ContractAddress}\n onClick={() => void runDeposit()}\n >\n Deposit\n </button>\n <button\n type=\"button\"\n style={{ ...ghost, flex: 1, opacity: busy ? 0.6 : 1 }}\n disabled={busy || !isConnected || !currencyID}\n onClick={() => void runWithdraw()}\n >\n Withdraw\n </button>\n </div>\n </>\n )}\n\n {result && <ResultLine result={result} />}\n </div>\n );\n}\n\nfunction ResultLine({ result }: { result: BridgeResult<unknown> }): ReactNode {\n const style: CSSProperties = {\n fontSize: 12,\n borderRadius: 8,\n padding: \"8px 10px\",\n background: result.ok ? \"#1e3a2a\" : \"#3a1e28\",\n color: result.ok ? \"#8ef0b0\" : \"#f2a0b4\",\n wordBreak: \"break-all\",\n };\n if (result.ok)\n return <div style={style}>βœ“ Done Β· tx {result.onChainTxHash}</div>;\n return (\n <div style={style}>\n βœ• [{result.stage}] {result.error}\n {result.titleTransactionID\n ? ` Β· already debited (tx ${result.titleTransactionID}) β€” retry/confirm, don't re-request`\n : \"\"}\n </div>\n );\n}\n"
47
+ },
48
+ {
49
+ "path": "controller-box.ts",
50
+ "content": "// A one-slot observable that bridges the imperative scene and the React panel. The EngineScene\n// creates the Phaser controller at mount time (it needs the canvas host), which is AFTER the panel\n// first renders; the panel subscribes here and re-renders once the controller is published. This is\n// what lets one controller instance be shared by the scene and the UI without prop-drilling across\n// the imperative/React boundary.\n\nexport interface ControllerBox<T> {\n get(): T | null;\n set(value: T | null): void;\n subscribe(listener: () => void): () => void;\n}\n\nexport function createControllerBox<T>(): ControllerBox<T> {\n let value: T | null = null;\n const listeners = new Set<() => void>();\n return {\n get: () => value,\n set(next) {\n value = next;\n for (const listener of [...listeners]) listener();\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n"
51
+ },
52
+ {
53
+ "path": "data/characterConfig.ts",
54
+ "content": "import type { IDosGamesClient } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics β€”\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n StatScalingFactor?: number;\n CostScalingFactor?: number;\n BaseCostResource?: {\n Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] };\n };\n}\n\nexport interface StatsPreset {\n Stats?: Record<string, StatDefinition>;\n}\n\nexport interface CharacterPresetBindings {\n Stats?: PresetBinding;\n Levels?: PresetBinding;\n Equipment?: PresetBinding;\n}\n\nexport interface CharacterDefinition {\n CharacterID?: string;\n Presets?: CharacterPresetBindings;\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n Stats?: Record<string, StatDefinition>;\n Classification?: { ClassID?: string; RarityID?: string };\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\n Unlock?: { UnlockedByDefault?: boolean };\n}\n\nexport interface CharacterPresetRegistry {\n Stats?: Record<string, StatsPreset>;\n Equipment?: Record<string, EquipmentPreset>;\n}\n\nexport interface CharacterConfig {\n Definitions?: Record<string, CharacterDefinition>;\n Presets?: CharacterPresetRegistry;\n}\n\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\n return client.data.config.getSection<CharacterConfig>(\"Character\") ?? {};\n}\n\nexport interface SlotInfo {\n slotID: string;\n minCharacterLevel: number;\n}\n\n/**\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveSlots(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): SlotInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const presetSlots = binding?.PresetID\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\n return Object.entries(slots).map(([slotID, rule]) => ({\n slotID,\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\n }));\n}\n\nexport interface StatInfo {\n statID: string;\n displayName: string;\n maxLevel?: number;\n baseValue: number;\n scaling: number;\n costScaling: number;\n costAmount: number;\n costCurrency?: string;\n}\n\n/**\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\n * StatID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveStats(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const presetStats = binding?.PresetID\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\n return Object.entries(stats).map(([statID, rule]) => {\n const entry = rule.BaseCostResource?.Standard?.Entries?.[0];\n return {\n statID,\n displayName: rule.DisplayName ?? statID,\n maxLevel: rule.MaxLevel,\n baseValue: rule.BaseStatValue ?? 0,\n scaling: rule.StatScalingFactor ?? 0,\n costScaling: rule.CostScalingFactor ?? 0,\n costAmount: entry?.Amount ?? 0,\n costCurrency: entry?.CurrencyID,\n };\n });\n}\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\nfunction mergeByKey<T>(\n preset: Record<string, T> | undefined,\n inline: Record<string, T> | undefined,\n remove: string[] | undefined,\n): Record<string, T> {\n if (!preset) return inline ?? {};\n const result: Record<string, T> = { ...preset, ...inline };\n for (const key of remove ?? []) delete result[key];\n return result;\n}\n\n/** Effective stat value at a given level (port of CharacterStatMath.ComputeValueAtLevel). */\nexport function computeStatValue(stat: StatInfo, level: number): number {\n if (level <= 1) return stat.baseValue;\n return stat.baseValue * (1 + stat.scaling * (level - 1));\n}\n\n/** Cost to upgrade a stat TO `nextLevel` (port of CharacterStatMath.ComputeUpgradeCost). */\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\n if (stat.costAmount <= 0) return 0;\n const scaled = stat.costAmount * (1 + stat.costScaling * (nextLevel - 1));\n return Math.max(0, Math.round(scaled));\n}\n\nexport type Availability = \"owned\" | \"available\" | \"locked\";\n\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\n * (first level-up creates the model); locked = must be unlocked. */\nexport function resolveAvailability(\n def: CharacterDefinition | undefined,\n hasModel: boolean,\n): Availability {\n if (hasModel) return \"owned\";\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\n return \"locked\";\n}\n"
55
+ },
56
+ {
57
+ "path": "env.ts",
58
+ "content": "// Runtime config the module reads from the page it runs in β€” the same source the host used: URL\n// params and the __IDOS_ENV__ global the host's bundler defines (see the host template's\n// vite.config). A module copied into a project runs in that same page, so these are available;\n// when absent (e.g. the classic preview bundler), safe defaults apply.\n//\n// titleID lives here because the wallet bridge needs it explicitly and the SDK client doesn't\n// expose it publicly. Resolving at runtime (not build time) lets one build serve staged + prod.\n\ntype IdosEnv = {\n TITLE_ID?: string;\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\nconst launchParams =\n typeof window === \"undefined\"\n ? null\n : new URLSearchParams(window.location.search);\n\nexport const TITLE_ID =\n launchParams?.get(\"titleID\") ?? (env.TITLE_ID || \"URLV9SUP\");\nexport const ENV_WALLETCONNECT_PROJECT_ID = env.WALLETCONNECT_PROJECT_ID ?? \"\";\n"
59
+ },
60
+ {
61
+ "path": "game/IdleRpgController.ts",
62
+ "content": "import Phaser from \"phaser\";\nimport { IdleRpgScene, type CharacterDisplay } from \"../phaser/IdleRpgScene\";\n\n/** Owns the Phaser game instance and bridges React β†’ scene (selecting a character). */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly scene: IdleRpgScene;\n\n constructor(parent: HTMLElement) {\n this.scene = new IdleRpgScene();\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: 480,\n height: 640,\n backgroundColor: \"#16122b\",\n scene: this.scene,\n scale: {\n // Hug the left of the full-bleed host so the host's UI panel can dock on the right without\n // covering the character card.\n mode: Phaser.Scale.FIT,\n autoCenter: Phaser.Scale.CENTER_VERTICALLY,\n },\n });\n }\n\n selectCharacter(display: CharacterDisplay): void {\n this.scene.setSelected(display);\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs). */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n destroy(): void {\n this.game.destroy(true);\n }\n}\n"
63
+ },
64
+ {
65
+ "path": "index.ts",
66
+ "content": "// @idosgames/mod-idle-rpg β€” the Idle RPG feature module (Phaser + React panels) for the host shell.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// (swap the UI, reuse the roster, drive the scene differently) after copying this into src/modules/.\n\nexport { idleRpgModule } from \"./module\";\n\nexport { IdleRpgController } from \"./game/IdleRpgController\";\nexport { IdleRpgScene, type CharacterDisplay } from \"./phaser/IdleRpgScene\";\nexport { createIdleRpgScene } from \"./scene\";\nexport { makeIdleRpgRootPanel } from \"./RootPanel\";\nexport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\n\nexport { CurrencyHud } from \"./components/CurrencyHud\";\nexport {\n CharacterRoster,\n useRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nexport { CharacterDetail } from \"./components/CharacterDetail\";\nexport { EquipmentPanel } from \"./components/EquipmentPanel\";\nexport { StatusBar } from \"./components/StatusBar\";\nexport { WalletPanel } from \"./components/WalletPanel\";\n\nexport {\n readCharacterConfig,\n resolveSlots,\n resolveStats,\n resolveAvailability,\n computeStatValue,\n computeUpgradeCost,\n type CharacterConfig,\n type CharacterDefinition,\n type Availability,\n} from \"./data/characterConfig\";\n"
67
+ },
68
+ {
69
+ "path": "module.ts",
70
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login β€” the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n ctx.registerScene(createIdleRpgScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeIdleRpgRootPanel(box),\n });\n ctx.registerRoute({ id: \"idle-rpg\", label: \"Idle RPG\", icon: \"βš”οΈ\" });\n },\n});\n"
71
+ },
72
+ {
73
+ "path": "phaser/IdleRpgScene.ts",
74
+ "content": "import Phaser from \"phaser\";\n\nexport interface CharacterDisplay {\n name: string;\n rarity?: string;\n className?: string;\n statusLine: string;\n owned: boolean;\n}\n\nconst RARITY_COLORS: Record<string, number> = {\n Common: 0x9aa0b5,\n Uncommon: 0x5ad19a,\n Rare: 0x4a90e2,\n Epic: 0x9c5cff,\n Legendary: 0xf0a020,\n Mythic: 0xe8534e,\n Exotic: 0xff5fa2,\n};\n\nfunction rarityColor(rarity?: string): number {\n const resolved = rarity ? RARITY_COLORS[rarity] : undefined;\n return resolved ?? 0x6c5ce7;\n}\n\n/** 2D character display: a rarity-framed card with a procedural emblem, the character's name/class,\n * and an idle bob. The IdleRPG screen is UI-driven (React overlays); Phaser owns the live visuals. */\nexport class IdleRpgScene extends Phaser.Scene {\n private container?: Phaser.GameObjects.Container;\n private current: CharacterDisplay | null = null;\n\n constructor() {\n super(\"idle-rpg\");\n }\n\n create(): void {\n const { width, height } = this.scale;\n this.cameras.main.setBackgroundColor(\"#16122b\");\n\n this.add\n .text(width / 2, 30, \"IdleRPG\", {\n fontFamily: \"sans-serif\",\n fontSize: \"30px\",\n color: \"#ffd479\",\n })\n .setOrigin(0.5, 0);\n\n this.container = this.add.container(width / 2, height / 2);\n this.tweens.add({\n targets: this.container,\n y: height / 2 - 12,\n yoyo: true,\n repeat: -1,\n duration: 1100,\n ease: \"Sine.easeInOut\",\n });\n\n this.render();\n }\n\n setSelected(display: CharacterDisplay): void {\n this.current = display;\n this.render();\n }\n\n private render(): void {\n const container = this.container;\n if (!container) return;\n container.removeAll(true);\n\n if (!this.current) {\n const glow = this.add.graphics();\n glow.fillStyle(0x6c5ce7, 0.12);\n glow.fillCircle(0, -30, 140);\n const ring = this.add.graphics();\n ring.lineStyle(3, 0x4a4368, 1);\n ring.strokeCircle(0, -40, 56);\n const hint = this.add\n .text(0, 70, \"Select a character\", {\n fontFamily: \"sans-serif\",\n fontSize: \"20px\",\n color: \"#8a82a8\",\n })\n .setOrigin(0.5);\n container.add([glow, ring, hint]);\n return;\n }\n\n const color = rarityColor(this.current.rarity);\n const initial = (this.current.className ?? this.current.name)\n .charAt(0)\n .toUpperCase();\n const dim = !this.current.owned;\n\n const glow = this.add.graphics();\n glow.fillStyle(color, dim ? 0.08 : 0.18);\n glow.fillCircle(0, -40, 150);\n\n const card = this.add.graphics();\n card.fillStyle(0x1b1730, 0.92);\n card.fillRoundedRect(-110, -165, 220, 300, 16);\n card.lineStyle(4, color, dim ? 0.5 : 1);\n card.strokeRoundedRect(-110, -165, 220, 300, 16);\n\n const emblem = this.add.graphics();\n emblem.fillStyle(color, dim ? 0.5 : 1);\n emblem.fillCircle(0, -72, 26);\n emblem.fillRoundedRect(-34, -46, 68, 86, 16);\n\n const initialText = this.add\n .text(0, -2, initial, {\n fontFamily: \"sans-serif\",\n fontSize: \"40px\",\n color: \"#1b1730\",\n fontStyle: \"bold\",\n })\n .setOrigin(0.5);\n\n const nameText = this.add\n .text(0, 70, this.current.name, {\n fontFamily: \"sans-serif\",\n fontSize: \"24px\",\n color: \"#ffffff\",\n fontStyle: \"bold\",\n })\n .setOrigin(0.5);\n\n const subParts = [this.current.rarity, this.current.className]\n .filter(Boolean)\n .join(\" Β· \");\n const subText = this.add\n .text(0, 100, subParts, {\n fontFamily: \"sans-serif\",\n fontSize: \"15px\",\n color: hexCss(color),\n })\n .setOrigin(0.5);\n\n const statusText = this.add\n .text(0, 126, this.current.statusLine, {\n fontFamily: \"sans-serif\",\n fontSize: \"15px\",\n color: \"#cfc6f0\",\n })\n .setOrigin(0.5);\n\n container.add([\n glow,\n card,\n emblem,\n initialText,\n nameText,\n subText,\n statusText,\n ]);\n }\n}\n\nfunction hexCss(color: number): string {\n return `#${color.toString(16).padStart(6, \"0\")}`;\n}\n"
75
+ },
76
+ {
77
+ "path": "react/controller.ts",
78
+ "content": "import { createControllerContext } from \"@idosgames/react\";\nimport type { IdleRpgController } from \"../game/IdleRpgController\";\n\n// The module's controller context, built with the shared factory instead of hand-written boilerplate.\n// Panels call useIdleRpgController() to reach the Phaser controller (e.g. to select a character).\nexport const [IdleRpgControllerProvider, useIdleRpgController] =\n createControllerContext<IdleRpgController>(\"IdleRpgController\");\n"
79
+ },
80
+ {
81
+ "path": "RootPanel.tsx",
82
+ "content": "import {\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { IdleRpgController } from \"./game/IdleRpgController\";\nimport { CurrencyHud } from \"./components/CurrencyHud\";\nimport {\n CharacterRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nimport { CharacterDetail } from \"./components/CharacterDetail\";\nimport { EquipmentPanel } from \"./components/EquipmentPanel\";\nimport { StatusBar } from \"./components/StatusBar\";\nimport { WalletPanel } from \"./components/WalletPanel\";\n\n// The module's whole UI as a single overlay panel. The host already provides the client + status\n// contexts (mountHost wraps the tree); this panel adds the module's own controller context once the\n// Phaser scene has published its controller into the shared box. It docks to the right so the\n// full-bleed Phaser character card stays visible on the left.\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeIdleRpgRootPanel(\n box: ControllerBox<IdleRpgController>,\n): ComponentType {\n return function IdleRpgRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading RPG…</div>;\n }\n return (\n <IdleRpgControllerProvider controller={controller}>\n <Layout />\n </IdleRpgControllerProvider>\n );\n };\n}\n\nfunction Layout(): ReactNode {\n const controller = useIdleRpgController();\n const [selected, setSelected] = useState<string | null>(null);\n\n const onSelect = (entry: RosterEntry): void => {\n setSelected(entry.id);\n controller.selectCharacter({\n name: entry.name,\n rarity: entry.rarity,\n className: entry.className,\n statusLine: rosterStatusLine(entry),\n owned: entry.availability === \"owned\",\n });\n };\n\n return (\n <div style={dockStyle}>\n <CurrencyHud />\n <div style={columnsStyle}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <CharacterRoster selectedID={selected} onSelect={onSelect} />\n </div>\n <div style={detailColumnStyle}>\n <CharacterDetail characterID={selected} />\n <EquipmentPanel characterID={selected} />\n <WalletPanel />\n </div>\n </div>\n <StatusBar />\n </div>\n );\n}\n\nconst dockStyle: CSSProperties = {\n position: \"absolute\",\n top: 0,\n right: 0,\n bottom: 0,\n width: \"min(560px, 62%)\",\n boxSizing: \"border-box\",\n padding: 16,\n overflow: \"auto\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(18,15,36,0.92)\",\n borderLeft: \"1px solid #2a2342\",\n};\nconst columnsStyle: CSSProperties = {\n display: \"flex\",\n gap: 16,\n alignItems: \"flex-start\",\n marginTop: 8,\n};\nconst detailColumnStyle: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n width: 280,\n flex: \"0 0 280px\",\n};\nconst loadingStyle: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n opacity: 0.6,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n};\n"
83
+ },
84
+ {
85
+ "path": "scene.ts",
86
+ "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Phaser controller as a host-driven EngineScene. Created lazily on mount (Phaser needs a\n// parent element), published into the shared box for the React panel, and paused/resumed by the\n// Mode Router via activate/suspend so a hidden mode stops ticking.\nexport function createIdleRpgScene(\n box: ControllerBox<IdleRpgController>,\n): EngineScene {\n let controller: IdleRpgController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new IdleRpgController(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"
87
+ }
88
+ ]
89
+ }