@idosgames/mcp 0.1.19 → 0.1.20
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/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +7 -7
- package/registry/index.json +437 -110
- package/registry/modules/ai.json +65 -0
- package/registry/modules/board-game.json +3 -3
- package/registry/modules/boosts.json +66 -0
- package/registry/modules/character.json +4 -5
- package/registry/modules/chat.json +4 -5
- package/registry/modules/collections.json +69 -0
- package/registry/modules/coop-events.json +67 -0
- package/registry/modules/craft.json +4 -5
- package/registry/modules/deals.json +4 -5
- package/registry/modules/events.json +8 -13
- package/registry/modules/friends.json +5 -5
- package/registry/modules/idle-rpg.json +3 -3
- package/registry/modules/inventory.json +15 -18
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +4 -5
- package/registry/modules/lootboxes.json +67 -0
- package/registry/modules/mailbox.json +71 -0
- package/registry/modules/marketplace.json +4 -5
- package/registry/modules/mini-game.json +2 -2
- package/registry/modules/multiplayer.json +74 -0
- package/registry/modules/premium.json +67 -0
- package/registry/modules/profile.json +9 -10
- package/registry/modules/pvp.json +4 -5
- package/registry/modules/quests.json +69 -0
- package/registry/modules/rewards.json +15 -21
- package/registry/modules/season.json +8 -13
- package/registry/modules/store.json +4 -5
- package/registry/modules/tutorials.json +4 -5
- package/registry/modules/voxelcraft.json +15 -11
- package/registry/modules/workshop.json +4 -5
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-ui-kit.json +2 -2
- package/registry/skills/mailbox-system.json +6 -0
- package/registry/skills/multiplayer-realtime.json +2 -2
- package/registry/skills/workshop-system.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -48,7 +48,7 @@ function moduleExportName(id) {
|
|
|
48
48
|
var TOOLS = [
|
|
49
49
|
{
|
|
50
50
|
name: "list_modules",
|
|
51
|
-
description: "List the composable iDosGames modules in the catalog. Each entry has id, name, type (template = a complete ready-to-run game to start FROM, e.g. voxelcraft; feature = a capability to add on top), engine, genre, a short summary, what it 'provides' (its features), tags, preview media, a live demo url, and its dependency count. Read 'provides' to reuse a ready module instead of building the feature from scratch, then get_module to pull its source. Entries may also carry 'sharedUi' ({provides, requires, drawsOwn}: shared chrome roles currency-bar | wallet | status | account | menu | modes \u2014 the project's base (sign-in + the lobby, see get_host_scaffold) provides currency-bar, wallet, account, menu and modes, and a game hides its own copies automatically) and 'inBase' (true: a system installed with the base in every new project \u2014
|
|
51
|
+
description: "List the composable iDosGames modules in the catalog. Each entry has id, name, type (template = a complete ready-to-run game to start FROM, e.g. voxelcraft; feature = a capability to add on top), engine, genre, a short summary, what it 'provides' (its features), tags, preview media, a live demo url, and its dependency count. Read 'provides' to reuse a ready module instead of building the feature from scratch, then get_module to pull its source. Entries may also carry 'sharedUi' ({provides, requires, drawsOwn}: shared chrome roles currency-bar | wallet | status | account | menu | modes \u2014 the project's base (sign-in + the lobby, see get_host_scaffold) provides currency-bar, wallet, account, menu and modes, and a game hides its own copies automatically) and 'inBase' (true: a system installed with the base in every new project \u2014 only profile, inventory, invite, friends and quests; its screen is a tab of the lobby; every other system is added only when the app needs it) and 'uses' (a game's modules: install them together with the game) and 'events' ({emits: [{topic, when, payload}], listens: [{topic, why}]}). To react to another module's event, copy its payload descriptor into your own defineTopic(topic, shape(payload)) \u2014 never import another module.",
|
|
52
52
|
inputSchema: { type: "object", properties: {} }
|
|
53
53
|
},
|
|
54
54
|
{
|
|
@@ -83,7 +83,7 @@ var TOOLS = [
|
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
name: "get_host_scaffold",
|
|
86
|
-
description: "Get the base every NEW project starts from \u2014 the host shell with sign-in and the lobby (index.html, main.tsx, src/base/, src/app.config.ts, empty src/modules.ts, config). Write these to the project root, then add modules with get_module: the systems with inBase (
|
|
86
|
+
description: "Get the base every NEW project starts from \u2014 the host shell with sign-in and the lobby (index.html, main.tsx, src/base/, src/app.config.ts, empty src/modules.ts, config). Write these to the project root, then add modules with get_module: the systems with inBase (profile, inventory, invite, friends, quests, mailbox), the game(s) with the modules each game 'uses', and any other system the app needs.",
|
|
87
87
|
inputSchema: { type: "object", properties: {} }
|
|
88
88
|
},
|
|
89
89
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idosgames/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/registry/host.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "package.json",
|
|
26
|
-
"content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (src/base/) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.
|
|
26
|
+
"content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (src/base/) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.8.0\",\n \"@idosgames/core\": \"0.19.1\",\n \"@idosgames/module-sdk\": \"0.7.0\",\n \"@idosgames/react\": \"0.3.0\",\n \"@idosgames/wallet\": \"0.6.0\",\n \"@reown/appkit\": \"1.8.23\",\n \"@reown/appkit-adapter-wagmi\": \"1.8.23\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
"path": "public/sw.js",
|
|
@@ -51,23 +51,23 @@
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"path": "src/base/lobby/i18n.ts",
|
|
54
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the lobby. Names of currencies, items and the rest are the publisher's words (title\n// config), and every tab's label is its module's own.\n\nconst EN = {\n lobby: \"Lobby\",\n toLobby: \"To the lobby\",\n play: \"Play\",\n more: \"More\",\n profile: \"Profile\",\n playerId: \"Player ID\",\n copyId: \"Copy ID\",\n copied: \"Copied\",\n logout: \"Log out\",\n wallet: \"Wallet\",\n noFeatures:\n \"Nothing to show yet. Tabs appear as the app's modules (shop, heroes, leaderboards…) get their settings on the title.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n lobby: \"Лобби\",\n toLobby: \"В лобби\",\n play: \"Играть\",\n more: \"Ещё\",\n profile: \"Профиль\",\n playerId: \"ID игрока\",\n copyId: \"Скопировать ID\",\n copied: \"Скопировано\",\n logout: \"Выйти\",\n wallet: \"Кошелёк\",\n noFeatures:\n \"Пока здесь пусто. Вкладки появляются, когда модулям приложения (магазин, герои, лидеры…) заданы настройки в тайтле.\",\n});\n"
|
|
54
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the lobby. Names of currencies, items and the rest are the publisher's words (title\n// config), and every tab's label is its module's own.\n\nconst EN = {\n lobby: \"Lobby\",\n toLobby: \"To the lobby\",\n play: \"Play\",\n withFriends: \"With friends\",\n more: \"More\",\n profile: \"Profile\",\n playerId: \"Player ID\",\n copyId: \"Copy ID\",\n copied: \"Copied\",\n logout: \"Log out\",\n wallet: \"Wallet\",\n noFeatures:\n \"Nothing to show yet. Tabs appear as the app's modules (shop, heroes, leaderboards…) get their settings on the title.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n lobby: \"Лобби\",\n toLobby: \"В лобби\",\n play: \"Играть\",\n withFriends: \"С друзьями\",\n more: \"Ещё\",\n profile: \"Профиль\",\n playerId: \"ID игрока\",\n copyId: \"Скопировать ID\",\n copied: \"Скопировано\",\n logout: \"Выйти\",\n wallet: \"Кошелёк\",\n noFeatures:\n \"Пока здесь пусто. Вкладки появляются, когда модулям приложения (магазин, герои, лидеры…) заданы настройки в тайтле.\",\n});\n"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"path": "src/base/lobby/Lobby.tsx",
|
|
58
|
-
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureEntry, FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n EmptyState,\n FeatureGrid,\n Icon,\n IconButton,\n ResourceCounter,\n outlined,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n type Layout,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\nimport { Account } from \"./Account\";\nimport { WalletButton } from \"./WalletButton\";\nimport { MORE, arrangeFeatures, splitTabs } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The lobby — the app's home. Its tabs are the modules' features (ctx.features — the shop, heroes,\n// leaders, rewards, the marketplace… and \"Play\" with a button per game); the lobby only lays them\n// out, in three layouts by its width:\n// - phone: balances, the wallet and the profile on top, the screen, 4 tabs + \"More\" at the bottom;\n// - tablet: the same frame, the tab bar a centred pill with room for 6;\n// - desktop: every feature in a side rail, grouped; the screen gets up to 1120px.\n// It is also the PRESENTER of features while on screen: `features.open(id)` from any module switches\n// the lobby to that screen. A feature the title has nothing for yet (`available: false`) is not\n// shown; remove a module from src/modules.ts and its tab is gone.\n\nexport interface LobbyOptions {\n features: FeatureRegistry;\n /** `lobby` of src/app.config.ts: tabs to put first / to lift out of \"More\". */\n lobby: AppConfig[\"lobby\"];\n /** Show the wallet button — a web3 title on an EVM network. */\n wallet: boolean;\n}\n\nconst CONTENT_MAX: Record<Layout, number> = {\n phone: 760,\n tablet: 900,\n desktop: 1120,\n};\n\nexport function makeLobbyPanel(options: LobbyOptions): ComponentType {\n return function LobbyPanel(): ReactNode {\n return <Lobby options={options} />;\n };\n}\n\ninterface Active {\n id: string;\n args?: Record<string, unknown>;\n}\n\nfunction Lobby({ options }: { options: LobbyOptions }): ReactNode {\n const { features } = options;\n const all = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const list = arrangeFeatures(\n all.filter((f) => f.available),\n options.lobby,\n );\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const rootRef = useRef<HTMLDivElement>(null);\n const layout = useLayout(rootRef);\n const [active, setActive] = useState<Active | null>(null);\n\n // While the lobby is on screen, \"open feature X\" from any module switches the lobby to X.\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setActive({ id, args });\n return true;\n }),\n [features],\n );\n\n const { tabs, more } = splitTabs(list, layout);\n const first = tabs[0];\n const current: Active | null =\n active && (active.id === MORE || list.some((f) => f.id === active.id))\n ? active\n : first\n ? { id: first.id }\n : null;\n const entry =\n current && current.id !== MORE ? features.get(current.id) : undefined;\n const inMore = current !== null && more.some((f) => f.id === current.id);\n const go = (id: string): void => setActive({ id });\n\n const balances = Object.entries(state?.InventoryV2?.VirtualCurrencies ?? {})\n .filter(\n ([id]) =>\n catalog.currencies[id] !== undefined ||\n Object.keys(catalog.currencies).length === 0,\n )\n .sort(([a], [b]) => a.localeCompare(b));\n\n const desktop = layout === \"desktop\";\n const pad = layout === \"phone\" ? 14 : 24;\n const frame: CSSProperties = desktop\n ? {\n gridTemplateColumns: \"232px minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n gridTemplateAreas: `\"tabs header\" \"tabs content\"`,\n }\n : {\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gridTemplateAreas: `\"header\" \"content\" \"tabs\"`,\n };\n\n const Screen = entry?.Screen;\n const moreBadge = more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0);\n const inventoryInMore = more.some((f) => f.id === \"inventory\");\n\n return (\n <div\n ref={rootRef}\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n ...frame,\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n }}\n >\n {/* Balances on the left, the wallet and the profile on the right */}\n <div\n style={{\n gridArea: \"header\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: `12px ${pad}px 6px`,\n minHeight: 20,\n }}\n >\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n flex: \"1 1 auto\",\n minWidth: 0,\n }}\n >\n {balances.map(([id, c]) => (\n <ResourceCounter\n key={id}\n currencyID={id}\n amount={Number(c?.Amount ?? 0)}\n onClick={\n list.some((f) => f.id === \"store\")\n ? () => go(\"store\")\n : undefined\n }\n />\n ))}\n </div>\n {options.wallet ? <WalletButton /> : null}\n <Account features={features} />\n </div>\n\n {/* The screen */}\n <div\n className=\"idos-scroll\"\n style={{\n gridArea: \"content\",\n overflowY: \"auto\",\n padding: `6px ${pad}px ${desktop ? 28 : 18}px`,\n }}\n >\n <div\n key={current?.id ?? \"none\"}\n style={{\n maxWidth: CONTENT_MAX[layout],\n margin: \"0 auto\",\n animation: `idos-fade-in ${v.normal} ease both`,\n }}\n >\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noFeatures\")} />\n ) : current?.id === MORE ? (\n <FeatureGrid items={more} onOpen={go} />\n ) : Screen && entry ? (\n <>\n {inMore && !desktop ? (\n <BackToMore\n label={catalog.localize(entry.label)}\n icon={entry.icon}\n onBack={() => go(MORE)}\n />\n ) : null}\n <Screen\n args={current?.args}\n close={() =>\n inMore ? go(MORE) : first ? go(first.id) : undefined\n }\n />\n </>\n ) : null}\n </div>\n </div>\n\n {list.length === 0 ? null : desktop ? (\n <SideRail\n features={tabs}\n current={current?.id ?? null}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n ) : (\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? { badge: moreBadge, counter: inventoryInMore }\n : null\n }\n current={inMore ? MORE : (current?.id ?? null)}\n pill={layout === \"tablet\"}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n )}\n </div>\n );\n}\n\nfunction BackToMore({\n label,\n icon,\n onBack,\n}: {\n label: string;\n icon?: string;\n onBack: () => void;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n margin: \"0 0 12px\",\n }}\n >\n <IconButton glyph=\"back\" label={uiT(\"back\")} onClick={onBack} size={36} />\n {icon ? <Icon glyph={icon} size={28} /> : null}\n <span style={{ ...outlined, fontSize: 19 }}>{label}</span>\n </div>\n );\n}\n\n/** Phone: a full-width tab strip at the bottom. Tablet: the same tabs as a centred pill. */\nfunction BottomTabs({\n tabs,\n more,\n current,\n pill,\n onPick,\n}: {\n tabs: FeatureEntry[];\n more: { badge: number; counter: boolean } | null;\n current: string | null;\n pill: boolean;\n onPick: (id: string) => void;\n}): ReactNode {\n const items = [\n ...tabs.map((f) => ({\n id: f.id,\n label: f.label,\n icon: f.icon ?? \"box\",\n badge: f.badge,\n })),\n ...(more\n ? [{ id: MORE, label: t(\"more\"), icon: \"menu\", badge: more.badge }]\n : []),\n ];\n return (\n <nav\n style={{\n gridArea: \"tabs\",\n display: \"grid\",\n gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,\n gap: items.length >= 5 ? 2 : 6,\n padding: `8px ${items.length >= 5 ? 4 : 10}px calc(8px + env(safe-area-inset-bottom))`,\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n // Longhands only: a shorthand `border` next to `borderTop` confuses React's style diffing.\n borderTop: `2px solid ${v.panelEdge}`,\n ...(pill\n ? {\n borderLeft: `2px solid ${v.panelEdge}`,\n borderRight: `2px solid ${v.panelEdge}`,\n borderBottom: `2px solid ${v.panelEdge}`,\n width: `min(${Math.max(520, items.length * 110)}px, calc(100% - 32px))`,\n justifySelf: \"center\",\n margin: \"0 0 12px\",\n padding: \"8px 10px\",\n borderRadius: 22,\n boxShadow: `0 6px 0 ${v.shadow}`,\n }\n : {}),\n }}\n >\n {items.map((tb) => {\n const active = tb.id === current;\n return (\n <button\n key={tb.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(tb.id)}\n data-tutorial-anchor={`tab:${tb.id}`}\n data-resource-counter={\n tb.id === \"inventory\" || (tb.id === MORE && more?.counter)\n ? \"inventory\"\n : undefined\n }\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 14,\n padding: pill ? \"8px 6px\" : \"6px 4px\",\n cursor: \"pointer\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n minWidth: 0,\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"transparent\",\n boxShadow: active\n ? \"0 3px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateY(-4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={tb.icon} size={active ? 30 : 26} />\n <span\n style={{\n ...outlined,\n fontSize: pill ? 13 : items.length >= 5 ? 10.5 : 12,\n letterSpacing: items.length >= 5 && !pill ? -0.2 : 0.2,\n color: active ? v.text : v.textDim,\n maxWidth: \"100%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {tb.label}\n </span>\n <Badge\n count={tb.badge}\n style={{ top: 0, right: \"calc(50% - 26px)\" }}\n />\n </button>\n );\n })}\n </nav>\n );\n}\n\n/** Desktop: every feature in a rail on the left — the main ones first, the rest grouped. */\nfunction SideRail({\n features,\n current,\n onPick,\n}: {\n features: FeatureEntry[];\n current: string | null;\n onPick: (id: string) => void;\n}): ReactNode {\n const main = features.filter((f) => f.primary);\n const rest = features.filter((f) => !f.primary);\n const row = (f: FeatureEntry): ReactNode => {\n const active = f.id === current;\n return (\n <button\n key={f.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(f.id)}\n data-tutorial-anchor={`tab:${f.id}`}\n data-resource-counter={f.id === \"inventory\" ? \"inventory\" : undefined}\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 16,\n padding: \"10px 14px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n textAlign: \"left\",\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.04)\",\n boxShadow: active\n ? \"0 4px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateX(4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={f.icon ?? \"box\"} size={f.primary ? 34 : 28} />\n <span\n style={{\n ...outlined,\n fontSize: f.primary ? 17 : 15,\n color: active ? v.text : v.textDim,\n }}\n >\n {f.label}\n </span>\n <Badge count={f.badge} style={{ top: 6, right: 8 }} />\n </button>\n );\n };\n return (\n <nav\n className=\"idos-scroll\"\n style={{\n gridArea: \"tabs\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n padding: \"20px 14px\",\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n borderRight: `2px solid ${v.panelEdge}`,\n overflowY: \"auto\",\n }}\n >\n {main.map(row)}\n {rest.length > 0 ? (\n <div\n style={{\n height: 2,\n background: v.panelEdge,\n opacity: 0.5,\n margin: \"6px 4px\",\n }}\n />\n ) : null}\n {rest.map(row)}\n </nav>\n );\n}\n"
|
|
58
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureEntry, FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n EmptyState,\n FeatureGrid,\n Icon,\n IconButton,\n ResourceCounter,\n outlined,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n type Layout,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\nimport { Account } from \"./Account\";\nimport { WalletButton } from \"./WalletButton\";\nimport { MORE, arrangeFeatures, splitTabs } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The lobby — the app's home. Its tabs are the modules' features (ctx.features — the shop, heroes,\n// leaders, rewards, the marketplace… and \"Play\" with a button per game); the lobby only lays them\n// out, in three layouts by its width:\n// - phone: balances, the wallet and the profile on top, the screen, 4 tabs + \"More\" at the bottom;\n// - tablet: the same frame, the tab bar a centred pill with room for 6;\n// - desktop: every feature in a side rail, grouped; the screen gets up to 1120px.\n// It is also the PRESENTER of features while on screen: `features.open(id)` from any module switches\n// the lobby to that screen. A feature the title has nothing for yet (`available: false`) is not\n// shown; remove a module from src/modules.ts and its tab is gone.\n\nexport interface LobbyOptions {\n features: FeatureRegistry;\n /** `lobby` of src/app.config.ts: tabs to put first / to lift out of \"More\". */\n lobby: AppConfig[\"lobby\"];\n /** Show the wallet button — a web3 title on an EVM network. */\n wallet: boolean;\n /**\n * A screen asked for while the lobby was not on screen (a game's \"Open for friends\"): the base\n * switched to the lobby, the lobby opens it on mount. See `createBaseModule`.\n */\n pending?: { take(): { id: string; args?: Record<string, unknown> } | null };\n}\n\nconst CONTENT_MAX: Record<Layout, number> = {\n phone: 760,\n tablet: 900,\n desktop: 1120,\n};\n\nexport function makeLobbyPanel(options: LobbyOptions): ComponentType {\n return function LobbyPanel(): ReactNode {\n return <Lobby options={options} />;\n };\n}\n\ninterface Active {\n id: string;\n args?: Record<string, unknown>;\n}\n\nfunction Lobby({ options }: { options: LobbyOptions }): ReactNode {\n const { features } = options;\n const all = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const list = arrangeFeatures(\n all.filter((f) => f.available),\n options.lobby,\n );\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const rootRef = useRef<HTMLDivElement>(null);\n const layout = useLayout(rootRef);\n const [active, setActive] = useState<Active | null>(\n () => options.pending?.take() ?? null,\n );\n\n // While the lobby is on screen, \"open feature X\" from any module switches the lobby to X.\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setActive({ id, args });\n return true;\n }),\n [features],\n );\n\n const { tabs, more } = splitTabs(list, layout);\n const first = tabs[0];\n const current: Active | null =\n active && (active.id === MORE || list.some((f) => f.id === active.id))\n ? active\n : first\n ? { id: first.id }\n : null;\n const entry =\n current && current.id !== MORE ? features.get(current.id) : undefined;\n const inMore = current !== null && more.some((f) => f.id === current.id);\n const go = (id: string): void => setActive({ id });\n\n const balances = Object.entries(state?.InventoryV2?.VirtualCurrencies ?? {})\n .filter(\n ([id]) =>\n catalog.currencies[id] !== undefined ||\n Object.keys(catalog.currencies).length === 0,\n )\n .sort(([a], [b]) => a.localeCompare(b));\n\n const desktop = layout === \"desktop\";\n const pad = layout === \"phone\" ? 14 : 24;\n const frame: CSSProperties = desktop\n ? {\n gridTemplateColumns: \"232px minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n gridTemplateAreas: `\"tabs header\" \"tabs content\"`,\n }\n : {\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gridTemplateAreas: `\"header\" \"content\" \"tabs\"`,\n };\n\n const Screen = entry?.Screen;\n const moreBadge = more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0);\n const inventoryInMore = more.some((f) => f.id === \"inventory\");\n\n return (\n <div\n ref={rootRef}\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n ...frame,\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n }}\n >\n {/* Balances on the left, the wallet and the profile on the right */}\n <div\n style={{\n gridArea: \"header\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: `12px ${pad}px 6px`,\n minHeight: 20,\n }}\n >\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n flex: \"1 1 auto\",\n minWidth: 0,\n }}\n >\n {balances.map(([id, c]) => (\n <ResourceCounter\n key={id}\n currencyID={id}\n amount={Number(c?.Amount ?? 0)}\n onClick={\n list.some((f) => f.id === \"store\")\n ? () => go(\"store\")\n : undefined\n }\n />\n ))}\n </div>\n {options.wallet ? <WalletButton /> : null}\n <Account features={features} />\n </div>\n\n {/* The screen */}\n <div\n className=\"idos-scroll\"\n style={{\n gridArea: \"content\",\n overflowY: \"auto\",\n padding: `6px ${pad}px ${desktop ? 28 : 18}px`,\n }}\n >\n <div\n key={current?.id ?? \"none\"}\n style={{\n maxWidth: CONTENT_MAX[layout],\n margin: \"0 auto\",\n animation: `idos-fade-in ${v.normal} ease both`,\n }}\n >\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noFeatures\")} />\n ) : current?.id === MORE ? (\n <FeatureGrid items={more} onOpen={go} />\n ) : Screen && entry ? (\n <>\n {inMore && !desktop ? (\n <BackToMore\n label={catalog.localize(entry.label)}\n icon={entry.icon}\n onBack={() => go(MORE)}\n />\n ) : null}\n <Screen\n args={current?.args}\n close={() =>\n inMore ? go(MORE) : first ? go(first.id) : undefined\n }\n />\n </>\n ) : null}\n </div>\n </div>\n\n {list.length === 0 ? null : desktop ? (\n <SideRail\n features={tabs}\n current={current?.id ?? null}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n ) : (\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? { badge: moreBadge, counter: inventoryInMore }\n : null\n }\n current={inMore ? MORE : (current?.id ?? null)}\n pill={layout === \"tablet\"}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n )}\n </div>\n );\n}\n\nfunction BackToMore({\n label,\n icon,\n onBack,\n}: {\n label: string;\n icon?: string;\n onBack: () => void;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n margin: \"0 0 12px\",\n }}\n >\n <IconButton glyph=\"back\" label={uiT(\"back\")} onClick={onBack} size={36} />\n {icon ? <Icon glyph={icon} size={28} /> : null}\n <span style={{ ...outlined, fontSize: 19 }}>{label}</span>\n </div>\n );\n}\n\n/** Phone: a full-width tab strip at the bottom. Tablet: the same tabs as a centred pill. */\nfunction BottomTabs({\n tabs,\n more,\n current,\n pill,\n onPick,\n}: {\n tabs: FeatureEntry[];\n more: { badge: number; counter: boolean } | null;\n current: string | null;\n pill: boolean;\n onPick: (id: string) => void;\n}): ReactNode {\n const items = [\n ...tabs.map((f) => ({\n id: f.id,\n label: f.label,\n icon: f.icon ?? \"box\",\n badge: f.badge,\n })),\n ...(more\n ? [{ id: MORE, label: t(\"more\"), icon: \"menu\", badge: more.badge }]\n : []),\n ];\n return (\n <nav\n style={{\n gridArea: \"tabs\",\n display: \"grid\",\n gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,\n gap: items.length >= 5 ? 2 : 6,\n padding: `8px ${items.length >= 5 ? 4 : 10}px calc(8px + env(safe-area-inset-bottom))`,\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n // Longhands only: a shorthand `border` next to `borderTop` confuses React's style diffing.\n borderTop: `2px solid ${v.panelEdge}`,\n ...(pill\n ? {\n borderLeft: `2px solid ${v.panelEdge}`,\n borderRight: `2px solid ${v.panelEdge}`,\n borderBottom: `2px solid ${v.panelEdge}`,\n width: `min(${Math.max(520, items.length * 110)}px, calc(100% - 32px))`,\n justifySelf: \"center\",\n margin: \"0 0 12px\",\n padding: \"8px 10px\",\n borderRadius: 22,\n boxShadow: `0 6px 0 ${v.shadow}`,\n }\n : {}),\n }}\n >\n {items.map((tb) => {\n const active = tb.id === current;\n return (\n <button\n key={tb.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(tb.id)}\n data-tutorial-anchor={`tab:${tb.id}`}\n data-resource-counter={\n tb.id === \"inventory\" || (tb.id === MORE && more?.counter)\n ? \"inventory\"\n : undefined\n }\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 14,\n padding: pill ? \"8px 6px\" : \"6px 4px\",\n cursor: \"pointer\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n minWidth: 0,\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"transparent\",\n boxShadow: active\n ? \"0 3px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateY(-4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={tb.icon} size={active ? 30 : 26} />\n <span\n style={{\n ...outlined,\n fontSize: pill ? 13 : items.length >= 5 ? 10.5 : 12,\n letterSpacing: items.length >= 5 && !pill ? -0.2 : 0.2,\n color: active ? v.text : v.textDim,\n maxWidth: \"100%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {tb.label}\n </span>\n <Badge\n count={tb.badge}\n style={{ top: 0, right: \"calc(50% - 26px)\" }}\n />\n </button>\n );\n })}\n </nav>\n );\n}\n\n/** Desktop: every feature in a rail on the left — the main ones first, the rest grouped. */\nfunction SideRail({\n features,\n current,\n onPick,\n}: {\n features: FeatureEntry[];\n current: string | null;\n onPick: (id: string) => void;\n}): ReactNode {\n const main = features.filter((f) => f.primary);\n const rest = features.filter((f) => !f.primary);\n const row = (f: FeatureEntry): ReactNode => {\n const active = f.id === current;\n return (\n <button\n key={f.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(f.id)}\n data-tutorial-anchor={`tab:${f.id}`}\n data-resource-counter={f.id === \"inventory\" ? \"inventory\" : undefined}\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 16,\n padding: \"10px 14px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n textAlign: \"left\",\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.04)\",\n boxShadow: active\n ? \"0 4px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateX(4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={f.icon ?? \"box\"} size={f.primary ? 34 : 28} />\n <span\n style={{\n ...outlined,\n fontSize: f.primary ? 17 : 15,\n color: active ? v.text : v.textDim,\n }}\n >\n {f.label}\n </span>\n <Badge count={f.badge} style={{ top: 6, right: 8 }} />\n </button>\n );\n };\n return (\n <nav\n className=\"idos-scroll\"\n style={{\n gridArea: \"tabs\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n padding: \"20px 14px\",\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n borderRight: `2px solid ${v.panelEdge}`,\n overflowY: \"auto\",\n }}\n >\n {main.map(row)}\n {rest.length > 0 ? (\n <div\n style={{\n height: 2,\n background: v.panelEdge,\n opacity: 0.5,\n margin: \"6px 4px\",\n }}\n />\n ) : null}\n {rest.map(row)}\n </nav>\n );\n}\n"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "src/base/lobby/model.ts",
|
|
62
|
-
"content": "// Pure helpers of the lobby — covered by model.test.ts.\n\nimport type { ModeEntry } from \"@idosgames/module-sdk\";\nimport {\n sortFeatures,\n type FeatureMenuItem,\n type Layout,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\n\n/** The pseudo-feature \"More\" — the rest of the features, as a grid. */\nexport const MORE = \"__more\";\n\n/** The lobby's own feature: the \"Play\" tab with a button per game. */\nexport const PLAY = \"play\";\n\nexport interface LobbyFeature extends FeatureMenuItem {\n primary?: boolean;\n}\n\n/** How many tabs fit next to \"More\" at this width (the desktop rail lists everything). */\nexport function tabSlots(layout: Layout): number {\n return layout === \"phone\"\n ? 5\n : layout === \"tablet\"\n ? 7\n : Number.POSITIVE_INFINITY;\n}\n\n/**\n * Applies app.config.ts on top of what the modules declared: the features named in `order` come\n * first, in that order, as main tabs; those in `primary` become main tabs too. Everything else keeps\n * the module's own `primary` / `order`.\n */\nexport function arrangeFeatures<T extends LobbyFeature>(\n features: readonly T[],\n lobby: AppConfig[\"lobby\"],\n): T[] {\n return features.map((f) => {\n const i = lobby.order.indexOf(f.id);\n if (i >= 0) return { ...f, primary: true, order: -1000 + i };\n if (lobby.primary.includes(f.id)) return { ...f, primary: true };\n return f;\n });\n}\n\n/**\n * Splits the game's features into the lobby's tabs and \"More\": main (primary) features by their\n * order take the tab slots first, secondary ones fill what is left; the rest goes under \"More\".\n * \"More\" appears only when it has something — an app with three screens has three tabs.\n */\nexport function splitTabs<T extends LobbyFeature>(\n features: readonly T[],\n layout: Layout,\n): { tabs: T[]; more: T[] } {\n const primary = [...features]\n .filter((f) => f.primary)\n .sort(\n (a, b) =>\n (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label),\n );\n const secondary = sortFeatures(features.filter((f) => !f.primary));\n const ordered = [...primary, ...secondary];\n const slots = tabSlots(layout);\n if (ordered.length <= slots) return { tabs: ordered, more: [] };\n // One slot goes to \"More\" itself; main features claim the rest first.\n const room = Math.max(1, slots - 1);\n return { tabs: ordered.slice(0, room), more: ordered.slice(room) };\n}\n\n/** The modes the lobby offers to play: those of `game` modules, in the host's order. */\nexport function gameModes(modes: readonly ModeEntry[]): ModeEntry[] {\n return modes.filter((m) => m.moduleType === \"game\");\n}\n\n
|
|
62
|
+
"content": "// Pure helpers of the lobby — covered by model.test.ts.\n\nimport type { ModeEntry } from \"@idosgames/module-sdk\";\nimport {\n sortFeatures,\n type FeatureMenuItem,\n type Layout,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\n\n/** The pseudo-feature \"More\" — the rest of the features, as a grid. */\nexport const MORE = \"__more\";\n\n/** The lobby's own feature: the \"Play\" tab with a button per game. */\nexport const PLAY = \"play\";\n\nexport interface LobbyFeature extends FeatureMenuItem {\n primary?: boolean;\n}\n\n/** How many tabs fit next to \"More\" at this width (the desktop rail lists everything). */\nexport function tabSlots(layout: Layout): number {\n return layout === \"phone\"\n ? 5\n : layout === \"tablet\"\n ? 7\n : Number.POSITIVE_INFINITY;\n}\n\n/**\n * Applies app.config.ts on top of what the modules declared: the features named in `order` come\n * first, in that order, as main tabs; those in `primary` become main tabs too. Everything else keeps\n * the module's own `primary` / `order`.\n */\nexport function arrangeFeatures<T extends LobbyFeature>(\n features: readonly T[],\n lobby: AppConfig[\"lobby\"],\n): T[] {\n return features.map((f) => {\n const i = lobby.order.indexOf(f.id);\n if (i >= 0) return { ...f, primary: true, order: -1000 + i };\n if (lobby.primary.includes(f.id)) return { ...f, primary: true };\n return f;\n });\n}\n\n/**\n * Splits the game's features into the lobby's tabs and \"More\": main (primary) features by their\n * order take the tab slots first, secondary ones fill what is left; the rest goes under \"More\".\n * \"More\" appears only when it has something — an app with three screens has three tabs.\n */\nexport function splitTabs<T extends LobbyFeature>(\n features: readonly T[],\n layout: Layout,\n): { tabs: T[]; more: T[] } {\n const primary = [...features]\n .filter((f) => f.primary)\n .sort(\n (a, b) =>\n (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label),\n );\n const secondary = sortFeatures(features.filter((f) => !f.primary));\n const ordered = [...primary, ...secondary];\n const slots = tabSlots(layout);\n if (ordered.length <= slots) return { tabs: ordered, more: [] };\n // One slot goes to \"More\" itself; main features claim the rest first.\n const room = Math.max(1, slots - 1);\n return { tabs: ordered.slice(0, room), more: ordered.slice(room) };\n}\n\n/** The modes the lobby offers to play: those of `game` modules, in the host's order. */\nexport function gameModes(modes: readonly ModeEntry[]): ModeEntry[] {\n return modes.filter((m) => m.moduleType === \"game\");\n}\n\n/**\n * Whether the lobby offers the wallet: a web3 title with a network — EVM or Solana alike, the panel\n * is the same for both (it picks the chain from the title's networks by itself).\n */\nexport function hasWallet(identity: {\n web3: boolean;\n networkID: string;\n}): boolean {\n return identity.web3 && identity.networkID.length > 0;\n}\n"
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"path": "src/base/lobby/PlayScreen.tsx",
|
|
66
|
-
"content": "import {\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureScreenProps,\n ModeEntry,\n ModeRegistry,\n} from \"@idosgames/module-sdk\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useStagger,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { gameModes } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The \"Play\" tab of the lobby: a button per installed game. One game — one big card; several — a\n// card each. Pressing it switches the host to the game's mode; the \"To the lobby\" button (see\n// BackToLobby.tsx) brings the player back.\n\nexport function makePlayScreen(\n modes: ModeRegistry,\n navigate: (modeId: string) => void,\n): ComponentType<FeatureScreenProps> {\n return function PlayScreen(): ReactNode {\n const list = useSyncExternalStore(modes.subscribe, modes.list, modes.list);\n const games = gameModes(list);\n const stagger = useStagger();\n const kit = useUiKit();\n const start = (mode: ModeEntry): void => {\n kit.play(\"open\");\n navigate(mode.id);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 14,\n gridTemplateColumns:\n games.length > 1\n ? \"repeat(auto-fill, minmax(240px, 1fr))\"\n : \"minmax(0, 1fr)\",\n maxWidth: games.length > 1 ? undefined : 520,\n margin: \"0 auto\",\n }}\n >\n {games.map((game, i) => (\n <div\n key={game.id}\n className=\"idos-shine\"\n style={{\n ...panel,\n ...stagger(i),\n padding: games.length > 1 ? 18 : 26,\n display: \"grid\",\n gap: 14,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon\n glyph={game.icon ?? \"star\"}\n size={games.length > 1 ? 56 : 84}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22, textAlign: \"center\" }}>\n {game.label}\n </div>\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => start(game)}\n data-tutorial-anchor={`play:${game.id}`}\n >\n ▶ {t(\"play\")}\n </Button>\n </div>\n ))}\n {games.length === 0 ? (\n <div style={{ ...outlined, color: v.textDim, textAlign: \"center\" }}>\n {t(\"noFeatures\")}\n </div>\n ) : null}\n </div>\n );\n };\n}\n"
|
|
66
|
+
"content": "import {\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n ModeEntry,\n ModeRegistry,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useStagger,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { gameModes } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The \"Play\" tab of the lobby: a button per installed game. One game — one big card; several — a\n// card each. Pressing it switches the host to the game's mode; the \"To the lobby\" button (see\n// BackToLobby.tsx) brings the player back. A game that plays online (registered in ctx.multiplayer)\n// also gets \"With friends\" — its rooms in the multiplayer system — while that system is on the app.\n\nexport function makePlayScreen(\n modes: ModeRegistry,\n navigate: (modeId: string) => void,\n online: MultiplayerRegistry,\n features: FeatureRegistry,\n): ComponentType<FeatureScreenProps> {\n return function PlayScreen(): ReactNode {\n const list = useSyncExternalStore(modes.subscribe, modes.list, modes.list);\n const onlineGames = useSyncExternalStore(\n online.subscribe,\n online.listGames,\n online.listGames,\n );\n const rooms = useSyncExternalStore(\n features.subscribe,\n () => features.get(\"multiplayer\")?.available === true,\n () => false,\n );\n const games = gameModes(list);\n const stagger = useStagger();\n const kit = useUiKit();\n const start = (mode: ModeEntry): void => {\n kit.play(\"open\");\n navigate(mode.id);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 14,\n gridTemplateColumns:\n games.length > 1\n ? \"repeat(auto-fill, minmax(240px, 1fr))\"\n : \"minmax(0, 1fr)\",\n maxWidth: games.length > 1 ? undefined : 520,\n margin: \"0 auto\",\n }}\n >\n {games.map((game, i) => (\n <div\n key={game.id}\n className=\"idos-shine\"\n style={{\n ...panel,\n ...stagger(i),\n padding: games.length > 1 ? 18 : 26,\n display: \"grid\",\n gap: 14,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon\n glyph={game.icon ?? \"star\"}\n size={games.length > 1 ? 56 : 84}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22, textAlign: \"center\" }}>\n {game.label}\n </div>\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => start(game)}\n data-tutorial-anchor={`play:${game.id}`}\n >\n ▶ {t(\"play\")}\n </Button>\n {rooms && onlineGames.some((g) => g.mode === game.id) ? (\n <Button\n tone=\"blue\"\n style={{ minWidth: 180 }}\n onClick={() => features.open(\"multiplayer\", { mode: game.id })}\n data-tutorial-anchor={`play-friends:${game.id}`}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"withFriends\")}\n </Button>\n ) : null}\n </div>\n ))}\n {games.length === 0 ? (\n <div style={{ ...outlined, color: v.textDim, textAlign: \"center\" }}>\n {t(\"noFeatures\")}\n </div>\n ) : null}\n </div>\n );\n };\n}\n"
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"path": "src/base/lobby/WalletButton.tsx",
|
|
70
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport {
|
|
70
|
+
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport {\n LazyWalletPanel,\n isEmbeddedInPlatform,\n isEmbeddedOnForeignSite,\n openPlatformWalletPanel,\n} from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Icon,\n Popup,\n outlined,\n useGameLayout,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The app's one wallet entry (deposit / withdraw), next to the balances in the lobby header, on any\n// web3 title — EVM or Solana, the panel is the same and picks the chain by itself.\n//\n// Where the game runs decides what the button does (owner, 2026-09-29):\n// - inside idosgames.com's frame — crypto goes through the SITE's wallet card only, so the button\n// asks the page to show it (`openPlatformWalletPanel`); the game never opens a wallet there;\n// - framed by somebody else's site — no button: there is no wallet of ours to show;\n// - by its own link (`{id}.idos.games`, a custom domain, the AI Coder preview) — the game's own\n// panel with its own wallet connection.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls Reown\n// AppKit into the startup graph. The wallet config is memoised per project id, so a wallet connected\n// on the sign-in screen is already connected here.\n\nexport function WalletButton(): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [open, setOpen] = useState(false);\n const embedded = isEmbeddedInPlatform();\n\n if (isEmbeddedOnForeignSite()) return null;\n\n return (\n <>\n <button\n type=\"button\"\n className=\"idos-press\"\n aria-label={t(\"wallet\")}\n onClick={() => {\n kit.play(\"click\");\n if (embedded) void openPlatformWalletPanel();\n else setOpen(true);\n }}\n style={chip}\n data-tutorial-anchor=\"lobby:wallet\"\n >\n <Icon glyph=\"👛\" size={layout === \"phone\" ? 26 : 22} />\n {layout !== \"phone\" ? <span>{t(\"wallet\")}</span> : null}\n </button>\n {open && !embedded ? (\n <Popup title={t(\"wallet\")} onClose={() => setOpen(false)} width={420}>\n <LazyWalletPanel client={client} appName=\"iDosGames\" />\n </Popup>\n ) : null}\n </>\n );\n}\n\nconst chip: CSSProperties = {\n ...outlined,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: \"3px 10px 3px 5px\",\n borderRadius: 999,\n border: `2px solid ${v.panelEdge}`,\n background: \"rgba(8, 10, 40, .6)\",\n boxShadow: \"inset 0 2px 4px rgba(0,0,0,.4)\",\n cursor: \"pointer\",\n whiteSpace: \"nowrap\",\n fontSize: 14,\n};\n"
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
"path": "src/base/login/loginIcons.ts",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"path": "src/base/module.ts",
|
|
90
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport
|
|
90
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport appConfig from \"../app.config\";\nimport { IDOS_WEB3, IDOS_WEB3_NETWORK_ID } from \"../idos.title\";\nimport { LOBBY_MODE, type AppConfig } from \"./app-config\";\nimport { makeLobbyPanel } from \"./lobby/Lobby\";\nimport { makePlayScreen } from \"./lobby/PlayScreen\";\nimport { makeBackToLobby } from \"./lobby/BackToLobby\";\nimport { PLAY, gameModes, hasWallet } from \"./lobby/model\";\nimport { t } from \"./lobby/i18n\";\n\n// The base of every app on the platform — the lobby, the home the player lands on after sign-in (the\n// sign-in itself is ./login, rendered by the host before any module runs). It is written against the\n// same module contract as everything in src/modules/, and main.tsx installs it first.\n//\n// - The lobby lays out the features of the installed modules (the shop, heroes, leaders…) as tabs,\n// with the balances, the wallet and the profile on top.\n// - Every game module's mode gets a \"Play\" button (the \"Play\" tab); over a game the base draws only\n// a small button back to the lobby.\n// - It takes the shared-UI roles it draws for everybody, so games hide their own balances and wallet,\n// and the host draws no nav, ☰ or \"Log out\" of its own.\n//\n// What happens after sign-in and the order of the tabs: ../app.config.ts.\nexport const baseModule: Module = createBaseModule(appConfig, {\n web3: IDOS_WEB3,\n networkID: IDOS_WEB3_NETWORK_ID,\n});\n\n/** The web3 identity of the project (src/idos.title.ts) — whether the lobby offers the wallet. */\nexport interface BaseWeb3 {\n web3: boolean;\n networkID: string;\n}\n\n/** The base for a given app.config — the project uses `baseModule`; a test harness makes its own. */\nexport function createBaseModule(\n config: AppConfig,\n identity: BaseWeb3,\n): Module {\n return defineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n ctx.registerRoute({\n id: LOBBY_MODE,\n label: t(\"lobby\"),\n icon: \"🏠\",\n order: -1000,\n });\n\n // The wallet lives next to the balances — for any web3 title (EVM or Solana).\n const wallet = hasWallet(identity);\n\n // `features.open(id)` from a game (the lobby is not on screen, its own presenter is gone): switch\n // to the lobby and let it open the screen on mount. While the lobby is shown, its presenter —\n // registered later — handles the request first.\n let pending: { id: string; args?: Record<string, unknown> } | null = null;\n ctx.features.setPresenter((id, args) => {\n pending = { id, args };\n ctx.navigate(LOBBY_MODE);\n return true;\n });\n\n ctx.registerPanel({\n id: \"lobby\",\n slot: \"overlay\",\n component: makeLobbyPanel({\n features: ctx.features,\n lobby: config.lobby,\n wallet,\n pending: {\n take: () => {\n const next = pending;\n pending = null;\n return next;\n },\n },\n }),\n });\n ctx.registerPanel({\n id: \"back\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBackToLobby(ctx.modes, ctx.navigate),\n });\n\n // \"Play\" — only while there is something to play. The modes are known once every module is\n // installed, so this follows the list instead of reading it here.\n const PlayScreen = makePlayScreen(\n ctx.modes,\n ctx.navigate,\n ctx.multiplayer,\n ctx.features,\n );\n let offPlay: (() => void) | null = null;\n const syncPlay = (): void => {\n const hasGames = gameModes(ctx.modes.list()).length > 0;\n if (hasGames && !offPlay)\n offPlay = ctx.features.register({\n id: PLAY,\n label: t(\"play\"),\n icon: \"star\",\n group: \"events\",\n order: 0,\n primary: true,\n Screen: PlayScreen,\n });\n else if (!hasGames && offPlay) {\n offPlay();\n offPlay = null;\n }\n };\n ctx.modes.subscribe(syncPlay);\n\n // app.config.ts `start`: a game's mode id starts straight in it (the host applies it on its first\n // render — no flash of the lobby). An id no module registered is ignored by the host.\n if (config.start !== LOBBY_MODE) ctx.navigate(config.start);\n\n // The lobby is ordinary React in the DOM: an agent reads it as a tree and presses its buttons.\n // What is not on screen goes here — above all, why a tab is missing.\n ctx.exposeToAgent({\n state: () => ({\n mode: ctx.modes.current(),\n start: config.start,\n games: gameModes(ctx.modes.list()).map((m) => m.id),\n wallet,\n features: ctx.features.list().map((f) => ({\n id: f.id,\n module: f.moduleId,\n available: f.available,\n ...(f.unavailableReason\n ? { hiddenBecause: f.unavailableReason }\n : {}),\n badge: f.badge,\n })),\n }),\n actions: {\n go: (args) => ctx.navigate(String(args?.mode ?? LOBBY_MODE)),\n open: (args) => ctx.features.open(String(args?.feature ?? \"\")),\n },\n describeActions: {\n go: 'go({ mode }) — switch to a mode: \"lobby\" or a game\\'s mode id',\n open: 'open({ feature }) — open a tab by feature id (\"store\", \"character\"…); false if it is hidden',\n },\n });\n },\n });\n}\n"
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
"path": "src/config.ts",
|