@idosgames/mcp 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +173 -140
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/boosts.json +7 -7
- package/registry/modules/character.json +34 -12
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +5 -5
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +5 -5
- package/registry/modules/events.json +5 -5
- package/registry/modules/friends.json +5 -5
- package/registry/modules/idle-rpg.json +93 -54
- package/registry/modules/inventory.json +5 -5
- package/registry/modules/invite.json +5 -5
- package/registry/modules/leaderboard.json +5 -5
- package/registry/modules/lootboxes.json +6 -6
- package/registry/modules/mailbox.json +5 -5
- package/registry/modules/marketplace.json +6 -6
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +6 -6
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +5 -5
- package/registry/modules/store.json +5 -5
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/community-marketing-system.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/mailbox-system.json +1 -1
- package/registry/skills/match-system.json +2 -2
- package/registry/skills/multiplayer-realtime.json +2 -2
package/registry/modules/ai.json
CHANGED
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
"version": "0.1.1"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@idosgames/core": "0.
|
|
34
|
-
"@idosgames/module-sdk": "0.8.
|
|
35
|
-
"@idosgames/react": "0.4.
|
|
33
|
+
"@idosgames/core": "0.21.0",
|
|
34
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
35
|
+
"@idosgames/react": "0.4.4",
|
|
36
36
|
"react": "19.2.7"
|
|
37
37
|
},
|
|
38
|
-
"contentHash": "
|
|
38
|
+
"contentHash": "dc80659403c6c5ec016f2bfc09bb11dac17ec7f532ffb6a38f4b5b4544536ab5",
|
|
39
39
|
"files": [
|
|
40
40
|
{
|
|
41
41
|
"path": "AiScreen.tsx",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"path": "module.ts",
|
|
58
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { AiScreen } from \"./AiScreen\";\nimport { hasTitleData, type AISection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The AI studio as a feature of the game: the AI features the publisher opened to players. No badge
|
|
58
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { AiScreen } from \"./AiScreen\";\nimport { hasTitleData, type AISection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The AI studio as a feature of the game: the AI features the publisher opened to players. No badge.\n// The engine does not put the \"AI\" section into the client's title config, so the features are read\n// once with their own call (it lands in the same config section, the screen draws from it).\nexport const aiModule: Module = defineModule({\n id: \"ai\",\n meta: { name: \"AI studio\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"ai\",\n label: t(\"title\"),\n icon: \"bulb\",\n group: \"social\",\n order: 80,\n available: false,\n Screen: AiScreen,\n });\n const decide = () =>\n ctx.features.setAvailable(\n \"ai\",\n hasTitleData(configSection<AISection>(ctx.client, \"AI\")),\n \"no AI features for players on the title (AI.Features, none untagged 'hidden')\",\n );\n decide();\n if (!configSection(ctx.client, \"AI\"))\n void ctx.client.ai.getDefinitions().then(decide);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"ai\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "module.meta.json",
|
|
@@ -45,21 +45,21 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@idosgames/core": "0.
|
|
49
|
-
"@idosgames/module-sdk": "0.8.
|
|
50
|
-
"@idosgames/react": "0.4.
|
|
51
|
-
"@idosgames/wallet": "0.6.
|
|
48
|
+
"@idosgames/core": "0.21.0",
|
|
49
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
50
|
+
"@idosgames/react": "0.4.4",
|
|
51
|
+
"@idosgames/wallet": "0.6.2",
|
|
52
52
|
"@tanstack/react-query": "5.101.2",
|
|
53
53
|
"react": "19.2.7",
|
|
54
54
|
"three": "0.185.1",
|
|
55
55
|
"viem": "2.55.2",
|
|
56
56
|
"wagmi": "3.7.2"
|
|
57
57
|
},
|
|
58
|
-
"contentHash": "
|
|
58
|
+
"contentHash": "beddabb5fc32d396c698ca1c39acbc788c3533f01381c82da81e9e86c737dccf",
|
|
59
59
|
"files": [
|
|
60
60
|
{
|
|
61
61
|
"path": "chrome.ts",
|
|
62
|
-
"content": "// Which shared chrome the board draws itself, and a tiny store for news from other modules.\n//\n// Next to the base (its lobby draws the balances and the wallet) the board hides its wallet button and\n// currency chips — `setup()` asks `ctx.sharedUi.shouldDraw(role)` once and closes over the\n// answer. Stage, tile and cycle chips are the board's own genre UI and always stay. Installed without\n// a base, nobody takes a role and the board is the complete game it always was.\n\nexport interface BoardChrome {\n /** The \"💰 Wallet\" button and its dropdown (role \"wallet\"). */\n wallet: boolean;\n /** Currency chips in the board HUD (role \"currency-bar\"). */\n balances: boolean;\n /** The status line under the action panel (role \"status\"). */\n status: boolean;\n}\n\n/** Everything drawn — the standalone board. */\nexport const FULL_CHROME: BoardChrome = {\n wallet: true,\n balances: true,\n status: true,\n};\n\n/**\n * The latest news line from another module (e.g. \"Idle RPG hero reached Lv 5\"). A store, not React\n * state: the event arrives through `ctx.events` in `setup()` — possibly while the board's panel is\n * unmounted because another mode is active — and the panel reads it whenever it shows.\n */\nexport interface NewsStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n push(text: string): void;\n clear(): void;\n}\n\nexport function createNewsStore(): NewsStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n const set = (next: string | null): void => {\n current = next;\n for (const listener of [...listeners]) listener();\n };\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n push: (text) => set(text),\n clear: () => set(null),\n };\n}\n"
|
|
62
|
+
"content": "// Which shared chrome the board draws itself, and a tiny store for news from other modules.\n//\n// Next to the base (its lobby draws the balances and the wallet) the board hides its wallet button and\n// currency chips — `setup()` asks `ctx.sharedUi.shouldDraw(role)` once and closes over the\n// answer. Stage, tile and cycle chips are the board's own genre UI and always stay. Installed without\n// a base, nobody takes a role and the board is the complete game it always was.\n\nexport interface BoardChrome {\n /** The \"💰 Wallet\" button and its dropdown (role \"wallet\"). */\n wallet: boolean;\n /** Currency chips in the board HUD (role \"currency-bar\"). */\n balances: boolean;\n /** The status line under the action panel (role \"status\"). */\n status: boolean;\n /**\n * A lobby owns the modes (role \"modes\") and draws its round \"back to the lobby\" button over the\n * game's top-left corner — the HUD row moves right to clear it.\n */\n lobbyButton: boolean;\n}\n\n/** Everything drawn — the standalone board. */\nexport const FULL_CHROME: BoardChrome = {\n wallet: true,\n balances: true,\n status: true,\n lobbyButton: false,\n};\n\n/**\n * The latest news line from another module (e.g. \"Idle RPG hero reached Lv 5\"). A store, not React\n * state: the event arrives through `ctx.events` in `setup()` — possibly while the board's panel is\n * unmounted because another mode is active — and the panel reads it whenever it shows.\n */\nexport interface NewsStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n push(text: string): void;\n clear(): void;\n}\n\nexport function createNewsStore(): NewsStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n const set = (next: string | null): void => {\n current = next;\n for (const listener of [...listeners]) listener();\n };\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n push: (text) => set(text),\n clear: () => set(null),\n };\n}\n"
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"path": "components/AttackPanel.tsx",
|
|
@@ -67,11 +67,11 @@
|
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"path": "components/BoardHud.tsx",
|
|
70
|
-
"content": "import {\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useUserState, useBoardState } from \"../react/hooks\";\nimport {\n readBoardConfig,\n resolveStageName,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport type { NewsStore } from \"../chrome\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst chip: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n};\nconst infoChip: CSSProperties = {\n ...chip,\n color: \"#cfe0ff\",\n background: \"#04276bcc\",\n};\nconst newsChip: CSSProperties = {\n ...chip,\n color: \"#fff\",\n background: \"#0d66fecc\",\n border: \"none\",\n cursor: \"pointer\",\n};\n\nconst noSubscribe = (): (() => void) => () => {};\nconst noNews = (): string | null => null;\n\nexport function BoardHud({\n showBalances = true,\n news,\n}: {\n /**
|
|
70
|
+
"content": "import {\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useUserState, useBoardState } from \"../react/hooks\";\nimport {\n readBoardConfig,\n resolveStageName,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport type { NewsStore } from \"../chrome\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst chip: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n};\nconst infoChip: CSSProperties = {\n ...chip,\n color: \"#cfe0ff\",\n background: \"#04276bcc\",\n};\nconst newsChip: CSSProperties = {\n ...chip,\n color: \"#fff\",\n background: \"#0d66fecc\",\n border: \"none\",\n cursor: \"pointer\",\n};\n\nconst noSubscribe = (): (() => void) => () => {};\nconst noNews = (): string | null => null;\n\nexport function BoardHud({\n showBalances = true,\n news,\n}: {\n /** Coin and shield chips — hidden when a shared-UI provider (the base's lobby) draws balances. */\n showBalances?: boolean;\n /** News from other modules; click the chip to dismiss it. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const board = useBoardState();\n const cfg = readBoardConfig(client);\n const headline = useSyncExternalStore(\n news?.subscribe ?? noSubscribe,\n news?.get ?? noNews,\n );\n\n const currencies: Record<string, UserVirtualCurrencyState> =\n state?.InventoryV2?.VirtualCurrencies ?? {};\n const amount = (id?: string): number =>\n id ? (currencies[id]?.Amount ?? 0) : 0;\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n const stageName = resolveStageName(cfg, stage);\n\n return (\n <div style={row}>\n {/* The rolls stay even next to the base: they are what the Roll button spends, and the base\n draws no balances over a game — without the chip the player could not tell how many rolls\n are left. Coins and shields are in the lobby's balances. */}\n {cfg.RollCurrencyID ? (\n <span style={chip}>🎲 {amount(cfg.RollCurrencyID)}</span>\n ) : null}\n {showBalances && cfg.SoftCurrencyID ? (\n <span style={chip}>🪙 {amount(cfg.SoftCurrencyID)}</span>\n ) : null}\n {showBalances && cfg.ShieldCurrencyID ? (\n <span style={chip}>🛡 {amount(cfg.ShieldCurrencyID)}</span>\n ) : null}\n <span style={infoChip}>\n Stage {stage}\n {stageName ? ` · ${stageName}` : \"\"}\n </span>\n <span style={infoChip}>\n Tile {board?.Position ?? 0}/{tileCount > 0 ? tileCount - 1 : 0}\n </span>\n <span style={infoChip}>Cycles {board?.CyclesCompleted ?? 0}</span>\n {headline ? (\n <button\n type=\"button\"\n style={newsChip}\n onClick={() => news?.clear()}\n title=\"Dismiss\"\n >\n {headline} ✕\n </button>\n ) : null}\n </div>\n );\n}\n"
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
"path": "components/BoardRoot.tsx",
|
|
74
|
-
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"../chrome\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: 16,\n fontFamily: \"system-ui, sans-serif\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot({\n chrome = FULL_CHROME,\n news,\n}: {\n /** Which shared chrome to draw here (the rest is drawn by a shared-UI provider such as the base). */\n chrome?: BoardChrome;\n /** News from other modules, shown as a chip in the board HUD. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [walletOpen, setWalletOpen] = useState(false);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.gameLoop.getBoardDefinition();\n await client.gameLoop.getUserBoardState();\n if (!active) return;\n const cfg = readBoardConfig(client);\n const board = client.data.user.state?.GameLoop?.Board;\n const stage = board?.StageLevel ?? 1;\n controller.loadBoard(\n resolveTiles(cfg, stage),\n resolveBuildingCount(cfg, stage),\n );\n controller.setPosition(board?.Position ?? 0);\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n setReady(true);\n })();\n return () => {\n active = false;\n };\n }, [client, controller]);\n\n // Keep city pad heights in sync with building levels as the cache updates.\n useEffect(() => {\n const off = client.on(\"user:anyUpdated\", () => {\n const board = client.data.user.state?.GameLoop?.Board;\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n });\n return off;\n }, [client, controller]);\n\n // No StatusProvider of our own: the host wraps every module in ONE root provider, and a nested one\n // would swallow the board's messages — a shared status line would never see them.\n return (\n <div style={overlay}>\n {chrome.wallet && (\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n )}\n {chrome.wallet && walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <BoardHud showBalances={chrome.balances} news={news} />\n <div style={footer}>\n {ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n {chrome.status && <StatusBar />}\n </div>\n </div>\n );\n}\n"
|
|
74
|
+
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"../chrome\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: 16,\n fontFamily: \"system-ui, sans-serif\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot({\n chrome = FULL_CHROME,\n news,\n}: {\n /** Which shared chrome to draw here (the rest is drawn by a shared-UI provider such as the base). */\n chrome?: BoardChrome;\n /** News from other modules, shown as a chip in the board HUD. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [walletOpen, setWalletOpen] = useState(false);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.gameLoop.getBoardDefinition();\n await client.gameLoop.getUserBoardState();\n if (!active) return;\n const cfg = readBoardConfig(client);\n const board = client.data.user.state?.GameLoop?.Board;\n const stage = board?.StageLevel ?? 1;\n controller.loadBoard(\n resolveTiles(cfg, stage),\n resolveBuildingCount(cfg, stage),\n );\n controller.setPosition(board?.Position ?? 0);\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n setReady(true);\n })();\n return () => {\n active = false;\n };\n }, [client, controller]);\n\n // Keep city pad heights in sync with building levels as the cache updates.\n useEffect(() => {\n const off = client.on(\"user:anyUpdated\", () => {\n const board = client.data.user.state?.GameLoop?.Board;\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n });\n return off;\n }, [client, controller]);\n\n // No StatusProvider of our own: the host wraps every module in ONE root provider, and a nested one\n // would swallow the board's messages — a shared status line would never see them.\n return (\n <div style={overlay}>\n {chrome.wallet && (\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n )}\n {chrome.wallet && walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <div style={chrome.lobbyButton ? { paddingLeft: 52 } : undefined}>\n <BoardHud showBalances={chrome.balances} news={news} />\n </div>\n <div style={footer}>\n {ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n {chrome.status && <StatusBar />}\n </div>\n </div>\n );\n}\n"
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"path": "components/BuildPanel.tsx",
|
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
},
|
|
136
136
|
{
|
|
137
137
|
"path": "module.ts",
|
|
138
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\nimport { createNewsStore, type BoardChrome } from \"./chrome\";\nimport { idleCharacterUpgraded } from \"./events\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n\n // Shared chrome: draw our own copy only while no other module (the base: the lobby draws them) took the role.\n const chrome: BoardChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n\n // Optional listen: news from Idle RPG, if it is installed. Subscribed here, not in a panel —\n // the board's panel is unmounted while the player is in the Idle RPG mode, exactly when it fires.\n const news = createNewsStore();\n ctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`⚔️ Idle RPG hero reached Lv ${level}`),\n );\n\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box, chrome, news),\n });\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" });\n\n // Поверхность для агента. Здесь она НАМЕРЕННО тонкая: игровой интерфейс этого модуля —\n // обычный React в DOM, агент его и так читает деревом и нажимает кнопки как человек.\n // Дублировать эти же действия в actions значило бы завести второй способ играть, который\n // разъедется с настоящим. Из canvas'а агенту не видно только состояние сцены — его и отдаём.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n news: news.get(),\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
|
|
138
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\nimport { createNewsStore, type BoardChrome } from \"./chrome\";\nimport { idleCharacterUpgraded } from \"./events\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n\n // Shared chrome: draw our own copy only while no other module (the base: the lobby draws them) took the role.\n const chrome: BoardChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n lobbyButton: !ctx.sharedUi.shouldDraw(\"modes\"),\n };\n\n // Optional listen: news from Idle RPG, if it is installed. Subscribed here, not in a panel —\n // the board's panel is unmounted while the player is in the Idle RPG mode, exactly when it fires.\n const news = createNewsStore();\n ctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`⚔️ Idle RPG hero reached Lv ${level}`),\n );\n\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box, chrome, news),\n });\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" });\n\n // Поверхность для агента. Здесь она НАМЕРЕННО тонкая: игровой интерфейс этого модуля —\n // обычный React в DOM, агент его и так читает деревом и нажимает кнопки как человек.\n // Дублировать эти же действия в actions значило бы завести второй способ играть, который\n // разъедется с настоящим. Из canvas'а агенту не видно только состояние сцены — его и отдаём.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n news: news.get(),\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
|
|
139
139
|
},
|
|
140
140
|
{
|
|
141
141
|
"path": "react/context.tsx",
|
|
@@ -31,16 +31,16 @@
|
|
|
31
31
|
"version": "0.1.0"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@idosgames/core": "0.
|
|
35
|
-
"@idosgames/module-sdk": "0.8.
|
|
36
|
-
"@idosgames/react": "0.4.
|
|
34
|
+
"@idosgames/core": "0.21.0",
|
|
35
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
36
|
+
"@idosgames/react": "0.4.4",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
|
-
"contentHash": "
|
|
39
|
+
"contentHash": "eee31d7c59cc94428199e9c8a5fcc4d46b700785a25e5e01503034b4a6b52135",
|
|
40
40
|
"files": [
|
|
41
41
|
{
|
|
42
42
|
"path": "BoostsScreen.tsx",
|
|
43
|
-
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveBoostWindowInfo } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ResourceList,\n SectionTitle,\n Timer,\n canAfford,\n configSection,\n errorText,\n outlined,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boostRows,\n durationParts,\n effectText,\n runningBoosts,\n type ActiveBoostView,\n type BoostEffectView,\n type BoostRow,\n type BoostSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts (engine TimedBoost): what runs now — the player's own boosts with their timers or charges,\n// and the title's global windows (a happy hour, a phase of a boost chain) — then the boosts the\n// player can activate, free or for currency. The server applies every boost; the screen only shows.\n\nexport function BoostsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const stagger = useStagger();\n const [windows, setWindows] = useState<ActiveBoostWindowInfo[]>([]);\n const section = configSection<BoostSection>(client, \"TimedBoost\");\n const rows = boostRows(section);\n const running = runningBoosts(\n (state?.TimedBoost as { Active?: Record<string, ActiveBoostView> } | null)\n ?.Active,\n now,\n );\n const liveWindows = windows.filter(\n (w) => !w.EndUtc || new Date(w.EndUtc).getTime() > now,\n );\n\n // Fresh on open: expired boosts drop out, global windows are resolved by the server.\n useEffect(() => {\n void client.timedBoost.getActive();\n void client.timedBoost\n .getActiveWindows()\n .then((r) => r.ok && setWindows(r.data.Windows ?? []));\n }, [client]);\n\n if (rows.length === 0 && running.length === 0 && liveWindows.length === 0)\n return <EmptyState glyph=\"energy\" text={t(\"noBoosts\")} />;\n\n const nameOf = (boostID: string | undefined) =>\n catalog.localize(\n rows.find((r) => r.boostID === boostID)?.name ?? boostID ?? \"\",\n );\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n {running.length + liveWindows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"running\")}</SectionTitle>\n {liveWindows.map((w, i) => (\n <RunningCard\n key={`w:${w.SourceID}:${w.PhaseID ?? \"\"}`}\n name={catalog.localize(w.DisplayName ?? w.SourceID ?? \"\")}\n effects={w.Effects ?? []}\n until={w.EndUtc ?? null}\n charges={null}\n global\n style={stagger(i)}\n />\n ))}\n {running.map((b, i) => (\n <RunningCard\n key={b.InstanceID}\n name={nameOf(b.BoostID)}\n effects={b.EffectSnapshot ? [b.EffectSnapshot] : []}\n until={b.ExpiresAtUtc ?? null}\n charges={b.RemainingCharges ?? null}\n style={stagger(liveWindows.length + i)}\n />\n ))}\n </div>\n ) : null}\n {rows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"boosts\")}</SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(220px, 1fr))\",\n gap: 10,\n }}\n >\n {rows.map((row, i) => (\n <BoostCard key={row.boostID} row={row} style={stagger(i)} />\n ))}\n </div>\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction EffectLine({ effect }: { effect: BoostEffectView }): ReactNode {\n const e = effectText(effect);\n return (\n <span style={{ ...outlined, fontSize: 14, color: v.gold }}>\n {e.size} {e.target === \"custom\" ? \"\" : t(`target_${e.target}`)}\n </span>\n );\n}\n\nfunction RunningCard({\n name,\n effects,\n until,\n charges,\n global,\n style,\n}: {\n name: string;\n effects: BoostEffectView[];\n until: string | null;\n charges: number | null;\n global?: boolean;\n style: CSSProperties;\n}): ReactNode {\n const now = useNow();\n return (\n <Card\n highlight\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr\",\n alignItems: \"center\",\n gap: 12,\n ...style,\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph={global ? \"clock\" : \"energy\"} size={40} />\n </div>\n <div style={{ display: \"grid\", gap: 4, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {name}\n {global ? (\n <span style={{ color: v.textDim, fontSize: 12 }}>\n {\" \"}\n · {t(\"forEveryone\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 10, flexWrap: \"wrap\" }}>\n {effects.map((e, i) => (\n <EffectLine key={i} effect={e} />\n ))}\n </div>\n {until ? <Timer at={until} now={now} label={t(\"endsIn\")} /> : null}\n {charges != null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"charges\")}: {charges}\n </div>\n ) : null}\n </div>\n </Card>\n );\n}\n\nfunction BoostCard({\n row,\n style,\n}: {\n row: BoostRow;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const { h, m } = durationParts(row.durationSeconds);\n\n const activate = async (optionID: string | undefined) => {\n setBusy(optionID ?? \"free\");\n const res = await client.timedBoost.activate(\n row.boostID,\n optionID ? { selectedOptionID: optionID } : undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n toast(t(\"activated\"), \"success\");\n };\n\n return (\n <div ref={ref} style={style}>\n <Card style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n <Icon glyph={row.icon} size={52} />\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(row.name)}\n </div>\n {row.effect ? <EffectLine effect={row.effect} /> : null}\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {row.durationSeconds > 0\n ? `${h > 0 ? `${h} ${t(\"hours\")} ` : \"\"}${m > 0 ? `${m} ${t(\"minutes\")}` : \"\"}`\n : null}\n {row.charges > 0 ? ` · ${t(\"charges\")}: ${row.charges}` : null}\n </div>\n {row.free ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"free\"}\n onClick={() => void activate(
|
|
43
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveBoostWindowInfo } from \"@idosgames/core\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ResourceList,\n SectionTitle,\n Timer,\n canAfford,\n configSection,\n errorText,\n outlined,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boostRows,\n durationParts,\n effectText,\n runningBoosts,\n type ActiveBoostView,\n type BoostEffectView,\n type BoostRow,\n type BoostSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts (engine TimedBoost): what runs now — the player's own boosts with their timers or charges,\n// and the title's global windows (a happy hour, a phase of a boost chain) — then the boosts the\n// player can activate, free or for currency. The server applies every boost; the screen only shows.\n\nexport function BoostsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const stagger = useStagger();\n const [windows, setWindows] = useState<ActiveBoostWindowInfo[]>([]);\n const section = configSection<BoostSection>(client, \"TimedBoost\");\n const rows = boostRows(section);\n const running = runningBoosts(\n (state?.TimedBoost as { Active?: Record<string, ActiveBoostView> } | null)\n ?.Active,\n now,\n );\n const liveWindows = windows.filter(\n (w) => !w.EndUtc || new Date(w.EndUtc).getTime() > now,\n );\n\n // Fresh on open: expired boosts drop out, global windows are resolved by the server.\n useEffect(() => {\n void client.timedBoost.getActive();\n void client.timedBoost\n .getActiveWindows()\n .then((r) => r.ok && setWindows(r.data.Windows ?? []));\n }, [client]);\n\n if (rows.length === 0 && running.length === 0 && liveWindows.length === 0)\n return <EmptyState glyph=\"energy\" text={t(\"noBoosts\")} />;\n\n const nameOf = (boostID: string | undefined) =>\n catalog.localize(\n rows.find((r) => r.boostID === boostID)?.name ?? boostID ?? \"\",\n );\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n {running.length + liveWindows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"running\")}</SectionTitle>\n {liveWindows.map((w, i) => (\n <RunningCard\n key={`w:${w.SourceID}:${w.PhaseID ?? \"\"}`}\n name={catalog.localize(w.DisplayName ?? w.SourceID ?? \"\")}\n effects={w.Effects ?? []}\n until={w.EndUtc ?? null}\n charges={null}\n global\n style={stagger(i)}\n />\n ))}\n {running.map((b, i) => (\n <RunningCard\n key={b.InstanceID}\n name={nameOf(b.BoostID)}\n effects={b.EffectSnapshot ? [b.EffectSnapshot] : []}\n until={b.ExpiresAtUtc ?? null}\n charges={b.RemainingCharges ?? null}\n style={stagger(liveWindows.length + i)}\n />\n ))}\n </div>\n ) : null}\n {rows.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"boosts\")}</SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(220px, 1fr))\",\n gap: 10,\n }}\n >\n {rows.map((row, i) => (\n <BoostCard key={row.boostID} row={row} style={stagger(i)} />\n ))}\n </div>\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction EffectLine({ effect }: { effect: BoostEffectView }): ReactNode {\n const e = effectText(effect);\n return (\n <span style={{ ...outlined, fontSize: 14, color: v.gold }}>\n {e.size} {e.target === \"custom\" ? \"\" : t(`target_${e.target}`)}\n </span>\n );\n}\n\nfunction RunningCard({\n name,\n effects,\n until,\n charges,\n global,\n style,\n}: {\n name: string;\n effects: BoostEffectView[];\n until: string | null;\n charges: number | null;\n global?: boolean;\n style: CSSProperties;\n}): ReactNode {\n const now = useNow();\n return (\n <Card\n highlight\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr\",\n alignItems: \"center\",\n gap: 12,\n ...style,\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph={global ? \"clock\" : \"energy\"} size={40} />\n </div>\n <div style={{ display: \"grid\", gap: 4, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {name}\n {global ? (\n <span style={{ color: v.textDim, fontSize: 12 }}>\n {\" \"}\n · {t(\"forEveryone\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 10, flexWrap: \"wrap\" }}>\n {effects.map((e, i) => (\n <EffectLine key={i} effect={e} />\n ))}\n </div>\n {until ? <Timer at={until} now={now} label={t(\"endsIn\")} /> : null}\n {charges != null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"charges\")}: {charges}\n </div>\n ) : null}\n </div>\n </Card>\n );\n}\n\nfunction BoostCard({\n row,\n style,\n}: {\n row: BoostRow;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const { h, m } = durationParts(row.durationSeconds);\n\n const activate = async (optionID: string | undefined) => {\n setBusy(optionID ?? \"free\");\n const res = await client.timedBoost.activate(\n row.boostID,\n optionID ? { selectedOptionID: optionID } : undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n toast(t(\"activated\"), \"success\");\n };\n\n return (\n <div ref={ref} style={style}>\n <Card style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n <Icon glyph={row.icon} size={52} />\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(row.name)}\n </div>\n {row.effect ? <EffectLine effect={row.effect} /> : null}\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {row.durationSeconds > 0\n ? `${h > 0 ? `${h} ${t(\"hours\")} ` : \"\"}${m > 0 ? `${m} ${t(\"minutes\")}` : \"\"}`\n : null}\n {row.charges > 0 ? ` · ${t(\"charges\")}: ${row.charges}` : null}\n </div>\n {/* A free option (often a one-time welcome boost) next to the paid ones, not instead of\n them: once it is used up the player still has a way to buy the boost. */}\n {row.free ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === (row.freeOptionID ?? \"free\")}\n onClick={() => void activate(row.freeOptionID)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`boost:${row.boostID}`}\n >\n {t(\"activate\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void activate(o.id)}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={\n row.free ? undefined : `boost:${row.boostID}`\n }\n >\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"path": "i18n.ts",
|
|
@@ -52,11 +52,11 @@
|
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
"path": "model.ts",
|
|
55
|
-
"content": "// Pure helpers of the boosts screen — covered by model.test.ts. The server applies boosts; these\n// only decide what the screen draws.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface BoostEffectView {\n Target?: string | null;\n Operation?: string | null;\n Value?: number | null;\n}\n\nexport interface BoostDefinitionView {\n BoostID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n Effect?: BoostEffectView | null;\n DurationSeconds?: number | null;\n Charges?: number | null;\n}\n\n/** The title's TimedBoost section, as far as the boosts screen cares. */\nexport interface BoostSection {\n Definitions?: Record<string, BoostDefinitionView | null> | null;\n ScheduledBoosts?: Record<string, unknown> | null;\n BoostChains?: Record<string, unknown> | null;\n}\n\n/** A boost running for the player (UserState.TimedBoost.Active). */\nexport interface ActiveBoostView {\n InstanceID?: string;\n BoostID?: string;\n ExpiresAtUtc?: string | null;\n RemainingCharges?: number | null;\n EffectSnapshot?: BoostEffectView | null;\n}\n\n/** What an effect touches, as the screen names it (engine EventModifierTarget, grouped). */\nexport type EffectTarget = \"all\" | \"quest\" | \"board\" | \"cost\" | \"custom\";\n\n/** What the effect reads as: the size (\"×2\", \"+50%\", \"+10\") and what it touches. */\nexport function effectText(effect: BoostEffectView | null | undefined): {\n size: string;\n target: EffectTarget;\n} {\n const value = Number(effect?.Value ?? 0);\n const op = effect?.Operation ?? \"Multiply\";\n const size =\n op === \"AddPercent\"\n ? `+${value}%`\n : op === \"AddFlat\"\n ? `${value >= 0 ? \"+\" : \"\"}${value}`\n : `×${value}`;\n const t = effect?.Target ?? \"\";\n const target: EffectTarget =\n t === \"AllReward\"\n ? \"all\"\n : t === \"QuestReward\"\n ? \"quest\"\n : t.endsWith(\"Cost\")\n ? \"cost\"\n : t.startsWith(\"Board\")\n ? \"board\"\n : \"custom\";\n return { size, target };\n}\n\nexport interface BoostRow {\n boostID: string;\n name: string;\n description: string;\n icon: string;\n effect: BoostEffectView | null;\n durationSeconds: number;\n charges: number;\n /** Ways to activate with currencies/items (a price paid in a store is not sold here). */\n options: Array<{ id: string; price: ResourceLine[] }>;\n /** No price at all — activates for nothing. */\n free: boolean;\n}\n\n/** The boosts the player can activate, by id. */\nexport function boostRows(\n section: BoostSection | null | undefined,\n): BoostRow[] {\n const rows: BoostRow[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const priced = Object.entries(def.PriceOptions ?? {}).filter(([, o]) => o);\n const
|
|
55
|
+
"content": "// Pure helpers of the boosts screen — covered by model.test.ts. The server applies boosts; these\n// only decide what the screen draws.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface BoostEffectView {\n Target?: string | null;\n Operation?: string | null;\n Value?: number | null;\n}\n\nexport interface BoostDefinitionView {\n BoostID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n Effect?: BoostEffectView | null;\n DurationSeconds?: number | null;\n Charges?: number | null;\n}\n\n/** The title's TimedBoost section, as far as the boosts screen cares. */\nexport interface BoostSection {\n Definitions?: Record<string, BoostDefinitionView | null> | null;\n ScheduledBoosts?: Record<string, unknown> | null;\n BoostChains?: Record<string, unknown> | null;\n}\n\n/** A boost running for the player (UserState.TimedBoost.Active). */\nexport interface ActiveBoostView {\n InstanceID?: string;\n BoostID?: string;\n ExpiresAtUtc?: string | null;\n RemainingCharges?: number | null;\n EffectSnapshot?: BoostEffectView | null;\n}\n\n/** What an effect touches, as the screen names it (engine EventModifierTarget, grouped). */\nexport type EffectTarget = \"all\" | \"quest\" | \"board\" | \"cost\" | \"custom\";\n\n/** What the effect reads as: the size (\"×2\", \"+50%\", \"+10\") and what it touches. */\nexport function effectText(effect: BoostEffectView | null | undefined): {\n size: string;\n target: EffectTarget;\n} {\n const value = Number(effect?.Value ?? 0);\n const op = effect?.Operation ?? \"Multiply\";\n const size =\n op === \"AddPercent\"\n ? `+${value}%`\n : op === \"AddFlat\"\n ? `${value >= 0 ? \"+\" : \"\"}${value}`\n : `×${value}`;\n const t = effect?.Target ?? \"\";\n const target: EffectTarget =\n t === \"AllReward\"\n ? \"all\"\n : t === \"QuestReward\"\n ? \"quest\"\n : t.endsWith(\"Cost\")\n ? \"cost\"\n : t.startsWith(\"Board\")\n ? \"board\"\n : \"custom\";\n return { size, target };\n}\n\nexport interface BoostRow {\n boostID: string;\n name: string;\n description: string;\n icon: string;\n effect: BoostEffectView | null;\n durationSeconds: number;\n charges: number;\n /** Ways to activate with currencies/items (a price paid in a store is not sold here). */\n options: Array<{ id: string; price: ResourceLine[] }>;\n /** No price at all — activates for nothing. */\n free: boolean;\n /** The free price option to activate with (an option whose cost is empty); undefined — none named. */\n freeOptionID?: string;\n}\n\n/** The boosts the player can activate, by id. */\nexport function boostRows(\n section: BoostSection | null | undefined,\n): BoostRow[] {\n const rows: BoostRow[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const priced = Object.entries(def.PriceOptions ?? {}).filter(([, o]) => o);\n const all = priced.map(([oid, o]) => {\n const lines = costLines(o?.Cost as never);\n return {\n id: o?.OptionID || oid,\n price: lines.filter((l) => l.kind !== \"other\"),\n // An option with an EMPTY cost is free (the demo \"welcome boost\"); one paid only in a store\n // (\"other\" lines) is not — and is not sold here.\n free: lines.length === 0,\n };\n });\n const options = all\n .filter((o) => o.price.length > 0)\n .map(({ id, price }) => ({ id, price }));\n const freeOption = all.find((o) => o.free);\n rows.push({\n boostID: def.BoostID || id,\n name: def.DisplayName || def.BoostID || id,\n description: def.Description || \"\",\n icon: def.AssetPaths?.icon ?? \"energy\",\n effect: def.Effect ?? null,\n durationSeconds: Math.max(0, Number(def.DurationSeconds ?? 0)),\n charges: Math.max(0, Number(def.Charges ?? 0)),\n options,\n free: priced.length === 0 || freeOption !== undefined,\n ...(freeOption ? { freeOptionID: freeOption.id } : {}),\n });\n }\n return rows.sort((a, b) => a.boostID.localeCompare(b.boostID));\n}\n\n/** Boosts running now: not expired and with charges left (the server filters the same on read). */\nexport function runningBoosts(\n active: Record<string, ActiveBoostView | null | undefined> | null | undefined,\n nowMs: number,\n): ActiveBoostView[] {\n return Object.values(active ?? {})\n .filter((b): b is ActiveBoostView => !!b)\n .filter(\n (b) =>\n (!b.ExpiresAtUtc || new Date(b.ExpiresAtUtc).getTime() > nowMs) &&\n (b.RemainingCharges == null || b.RemainingCharges > 0),\n )\n .sort(\n (a, b) =>\n new Date(a.ExpiresAtUtc ?? 0).getTime() -\n new Date(b.ExpiresAtUtc ?? 0).getTime(),\n );\n}\n\n/** Hours and minutes of a boost's duration (seconds). */\nexport function durationParts(seconds: number): { h: number; m: number } {\n const total = Math.round(seconds / 60);\n return { h: Math.floor(total / 60), m: total % 60 };\n}\n\n/** Whether the title has boosts — personal ones or global windows. */\nexport function hasTitleData(section: BoostSection | undefined): boolean {\n return (\n Object.values(section?.Definitions ?? {}).some(Boolean) ||\n Object.keys(section?.ScheduledBoosts ?? {}).length > 0 ||\n Object.keys(section?.BoostChains ?? {}).length > 0\n );\n}\n"
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"path": "module.ts",
|
|
59
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { BoostsScreen } from \"./BoostsScreen\";\nimport { hasTitleData, type BoostSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts as a feature of the game: what runs now (the player's own boosts and the title's global\n// windows) and what can be activated. No badge: a running boost asks nothing of the player.\nexport const boostsModule: Module = defineModule({\n id: \"boosts\",\n meta: { name: \"Boosts\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"boosts\",\n label: t(\"title\"),\n icon: \"energy\",\n group: \"economy\",\n order: 58,\n available: false,\n Screen: BoostsScreen,\n });\n ctx.features.setAvailable(\n
|
|
59
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { BoostsScreen } from \"./BoostsScreen\";\nimport { hasTitleData, type BoostSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Boosts as a feature of the game: what runs now (the player's own boosts and the title's global\n// windows) and what can be activated. No badge: a running boost asks nothing of the player.\nexport const boostsModule: Module = defineModule({\n id: \"boosts\",\n meta: { name: \"Boosts\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"boosts\",\n label: t(\"title\"),\n icon: \"energy\",\n group: \"economy\",\n order: 58,\n available: false,\n Screen: BoostsScreen,\n });\n const decide = () =>\n ctx.features.setAvailable(\n \"boosts\",\n hasTitleData(configSection<BoostSection>(ctx.client, \"TimedBoost\")),\n \"no boosts on the title (TimedBoost)\",\n );\n decide();\n // The engine does not put TimedBoost into the client's title config: read it once with its own\n // call (it lands in the same config section, so the screen draws from it too). Without this the\n // tab never showed on any title.\n if (!configSection(ctx.client, \"TimedBoost\"))\n void ctx.client.timedBoost.getDefinitions().then(decide);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"boosts\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "module.meta.json",
|