@idosgames/mcp 0.1.34 → 0.1.35
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 +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +42 -42
- package/registry/modules/ai.json +1 -1
- package/registry/modules/board-game.json +93 -17
- package/registry/modules/boosts.json +1 -1
- package/registry/modules/character.json +20 -12
- package/registry/modules/chat.json +1 -1
- package/registry/modules/collections.json +1 -1
- package/registry/modules/coop-events.json +1 -1
- package/registry/modules/craft.json +1 -1
- package/registry/modules/deals.json +1 -1
- package/registry/modules/events.json +1 -1
- package/registry/modules/friends.json +1 -1
- package/registry/modules/idle-rpg.json +1 -1
- package/registry/modules/inventory.json +1 -1
- package/registry/modules/invite.json +1 -1
- package/registry/modules/leaderboard.json +1 -1
- package/registry/modules/lootboxes.json +3 -3
- package/registry/modules/mailbox.json +1 -1
- package/registry/modules/marketplace.json +1 -1
- package/registry/modules/mini-game.json +1 -1
- package/registry/modules/multiplayer.json +5 -5
- package/registry/modules/premium.json +1 -1
- package/registry/modules/profile.json +1 -1
- package/registry/modules/pvp.json +1 -1
- package/registry/modules/quests.json +1 -1
- package/registry/modules/rewards.json +1 -1
- package/registry/modules/season.json +1 -1
- package/registry/modules/store.json +1 -1
- package/registry/modules/tutorials.json +1 -1
- package/registry/modules/voxelcraft.json +11 -11
- package/registry/modules/workshop.json +1 -1
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@idosgames/core": "0.21.6",
|
|
36
36
|
"@idosgames/module-sdk": "0.11.0",
|
|
37
|
-
"@idosgames/react": "0.5.
|
|
37
|
+
"@idosgames/react": "0.5.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
|
-
"contentHash": "
|
|
40
|
+
"contentHash": "44d612ef23e30ce667db14ffd24b82fe6076efafad2e56d5c0d86613610059e2",
|
|
41
41
|
"files": [
|
|
42
42
|
{
|
|
43
43
|
"path": "i18n.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "LootboxesScreen.tsx",
|
|
52
|
-
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/** The box shakes while the server rolls it, bursts open, and the rewards pop out. */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const openBox = async (close: () => void) => {\n setBusy(true);\n const started = Date.now();\n const res = await client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 700 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n setBusy(false);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n close();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={\n count > 1\n ? `${lootboxName(option.lootboxID, catalog.localize)} ×${count}`\n : lootboxName(option.lootboxID, catalog.localize)\n }\n onClose={onClose}\n variant=\"center\"\n >\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 12,\n padding: \"10px 0\",\n }}\n >\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={120} />\n </div>\n <ResourceList\n lines={option.price.map((l) => ({ ...l, amount: l.amount * count }))}\n />\n </div>\n <OpenButton busy={busy} onOpen={openBox} />\n </Popup>\n );\n}\n\nfunction OpenButton({\n busy,\n onOpen,\n}: {\n busy: boolean;\n onOpen: (close: () => void) => Promise<void>;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void onOpen(close)}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n );\n}\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function watchLootboxes(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
|
|
52
|
+
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\r\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\r\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\r\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\r\nimport {\r\n Button,\r\n Card,\r\n EmptyState,\r\n Icon,\r\n Popup,\r\n ResourceList,\r\n configSection,\r\n errorText,\r\n grantedBy,\r\n outlined,\r\n useCatalog,\r\n useCelebrate,\r\n usePopupClose,\r\n useSound,\r\n useStagger,\r\n useToast,\r\n useUiKit,\r\n v,\r\n} from \"@idosgames/react/ui\";\r\nimport {\r\n BULK,\r\n canOpenBulk,\r\n canPay,\r\n lootboxName,\r\n lootboxOptions,\r\n lootboxesOpenedBy,\r\n openableWithItems,\r\n type InventoryView,\r\n type LootboxOption,\r\n} from \"./model\";\r\nimport { t } from \"./i18n\";\r\n\r\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\r\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\r\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\r\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\r\n\r\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\r\n const client = useIDosGamesClient();\r\n const state = useUserState();\r\n const catalog = useCatalog();\r\n const stagger = useStagger();\r\n const section = configSection(client, \"Lootbox\");\r\n const all = useMemo(() => lootboxOptions(section), [section]);\r\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\r\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\r\n const [opening, setOpening] = useState<{\r\n option: LootboxOption;\r\n count: number;\r\n } | null>(null);\r\n\r\n // Handed an item that opens exactly one chest — open its popup right away.\r\n useEffect(() => {\r\n if (itemID && options.length === 1)\r\n setOpening({ option: options[0]!, count: 1 });\r\n }, [itemID]);\r\n\r\n const inventory = state?.InventoryV2 as InventoryView | undefined;\r\n if (options.length === 0)\r\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\r\n return (\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\r\n gap: 12,\r\n }}\r\n >\r\n {options.map((o, i) => {\r\n const affordable = canPay(o, inventory);\r\n const bulk = canOpenBulk(o, inventory);\r\n return (\r\n <Card\r\n key={`${o.lootboxID}:${o.optionID}`}\r\n style={{\r\n display: \"grid\",\r\n justifyItems: \"center\",\r\n gap: 8,\r\n ...stagger(i),\r\n }}\r\n >\r\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\r\n {lootboxName(o.lootboxID, catalog.localize)}\r\n </div>\r\n <div className=\"idos-bounce\">\r\n <Icon glyph={o.icon} size={64} fallback=\"chest\" />\r\n </div>\r\n {o.name ? (\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {catalog.localize(o.name)}\r\n </div>\r\n ) : null}\r\n <Button\r\n tone=\"gold\"\r\n attract={affordable}\r\n disabled={!affordable}\r\n onClick={() => setOpening({ option: o, count: 1 })}\r\n style={{ width: \"100%\" }}\r\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\r\n >\r\n {o.price.length === 0 ? (\r\n t(\"open\")\r\n ) : (\r\n <ResourceList lines={o.price} size={20} />\r\n )}\r\n </Button>\r\n {bulk ? (\r\n <Button\r\n tone=\"blue\"\r\n size=\"sm\"\r\n onClick={() => setOpening({ option: o, count: BULK })}\r\n style={{ width: \"100%\" }}\r\n >\r\n {t(\"openTimes\")} ×{BULK}\r\n </Button>\r\n ) : null}\r\n </Card>\r\n );\r\n })}\r\n {opening ? (\r\n <LootboxOpening\r\n option={opening.option}\r\n count={opening.count}\r\n onClose={() => setOpening(null)}\r\n />\r\n ) : null}\r\n </div>\r\n );\r\n}\r\n\r\n/** The box shakes while the server rolls it, bursts open, and the rewards pop out. */\r\nfunction LootboxOpening({\r\n option,\r\n count,\r\n onClose,\r\n}: {\r\n option: LootboxOption;\r\n count: number;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const celebrate = useCelebrate();\r\n const toast = useToast();\r\n const play = useSound();\r\n const kit = useUiKit();\r\n const boxRef = useRef<HTMLDivElement>(null);\r\n const [busy, setBusy] = useState(false);\r\n\r\n const openBox = async (close: () => void) => {\r\n setBusy(true);\r\n const started = Date.now();\r\n const res = await client.lootbox.open(\r\n option.lootboxID,\r\n count,\r\n option.optionID,\r\n );\r\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\r\n const wait = Math.max(\r\n 0,\r\n (kit.motion.on ? 700 : 0) - (Date.now() - started),\r\n );\r\n await new Promise((r) => setTimeout(r, wait));\r\n setBusy(false);\r\n if (!res.ok) {\r\n toast(errorText(res.error), \"error\");\r\n return;\r\n }\r\n play(\"lootbox\");\r\n kit.fx.burst(boxRef.current);\r\n close();\r\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\r\n // (`Results` is the same per box; adding both would count everything twice).\r\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\r\n };\r\n\r\n return (\r\n <Popup\r\n title={\r\n count > 1\r\n ? `${lootboxName(option.lootboxID, catalog.localize)} ×${count}`\r\n : lootboxName(option.lootboxID, catalog.localize)\r\n }\r\n onClose={onClose}\r\n variant=\"center\"\r\n >\r\n <div\r\n style={{\r\n display: \"grid\",\r\n justifyItems: \"center\",\r\n gap: 12,\r\n padding: \"10px 0\",\r\n }}\r\n >\r\n <div\r\n ref={boxRef}\r\n style={{\r\n animation: busy\r\n ? \"idos-shake .35s ease-in-out infinite\"\r\n : \"idos-bounce 1.2s ease-in-out infinite\",\r\n }}\r\n >\r\n <Icon glyph={option.icon} size={120} fallback=\"chest\" />\r\n </div>\r\n <ResourceList\r\n lines={option.price.map((l) => ({ ...l, amount: l.amount * count }))}\r\n />\r\n </div>\r\n <OpenButton busy={busy} onOpen={openBox} />\r\n </Popup>\r\n );\r\n}\r\n\r\nfunction OpenButton({\r\n busy,\r\n onOpen,\r\n}: {\r\n busy: boolean;\r\n onOpen: (close: () => void) => Promise<void>;\r\n}): ReactNode {\r\n const close = usePopupClose();\r\n return (\r\n <Button\r\n tone=\"gold\"\r\n size=\"lg\"\r\n attract\r\n busy={busy}\r\n onClick={() => void onOpen(close)}\r\n >\r\n {busy ? t(\"opening\") : t(\"open\")}\r\n </Button>\r\n );\r\n}\r\n\r\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\r\nexport function watchLootboxes(ctx: ModuleContext): void {\r\n const { client, features } = ctx;\r\n ctx.effect(() => {\r\n const update = () =>\r\n features.setBadge(\r\n \"lootboxes\",\r\n openableWithItems(\r\n lootboxOptions(configSection(client, \"Lootbox\")),\r\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\r\n ),\r\n );\r\n update();\r\n return client.on(\"user:anyUpdated\", update);\r\n });\r\n}\r\n"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "model.ts",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@idosgames/core": "0.21.6",
|
|
40
40
|
"@idosgames/module-sdk": "0.11.0",
|
|
41
|
-
"@idosgames/react": "0.5.
|
|
41
|
+
"@idosgames/react": "0.5.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "6863f9fdc8aa3520038c623143ed8b3905c8ab7ae4f4498ab999236732e9e60c",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@idosgames/core": "0.21.6",
|
|
40
40
|
"@idosgames/module-sdk": "0.11.0",
|
|
41
|
-
"@idosgames/react": "0.5.
|
|
41
|
+
"@idosgames/react": "0.5.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "9c279916de34ee33f0993ebc3f73dec8885a343110e6ad36f3c984a909b45060",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@idosgames/core": "0.21.6",
|
|
34
34
|
"@idosgames/module-sdk": "0.11.0",
|
|
35
|
-
"@idosgames/react": "0.5.
|
|
35
|
+
"@idosgames/react": "0.5.1",
|
|
36
36
|
"react": "19.2.7"
|
|
37
37
|
},
|
|
38
38
|
"contentHash": "eb39139d93bfb84e5a20d2485b081a264234452b5d90978fdf15b940cf3b7ac0",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
"name": "iDos Games",
|
|
33
33
|
"url": "https://idosgames.com"
|
|
34
34
|
},
|
|
35
|
-
"version": "0.1.
|
|
35
|
+
"version": "0.1.4"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@idosgames/core": "0.21.6",
|
|
39
39
|
"@idosgames/module-sdk": "0.11.0",
|
|
40
|
-
"@idosgames/react": "0.5.
|
|
40
|
+
"@idosgames/react": "0.5.1",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "99fdaee28901b7e53a81d3101ae69c760f8d93af56cd438378afc791c7782938",
|
|
44
44
|
"files": [
|
|
45
45
|
{
|
|
46
46
|
"path": "components/CodeForm.tsx",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
"path": "rooms.ts",
|
|
87
|
-
"content": "import type { IDosGamesClient } from \"@idosgames/core\";\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\nimport type {\n LoadingReporter,\n MultiplayerRegistry,\n MultiplayerRoom,\n} from \"@idosgames/module-sdk\";\nimport {\n START_KEY,\n decodeStart,\n encodeStart,\n settingsFromState,\n settingsToState,\n startedMode,\n} from \"./model\";\n\n// The player's room, owned by the module rather than by its screen: it has to outlive the screen —\n// the room goes into a game and comes back to the lobby, and the session (with its WebRTC links) is\n// the same all along. The screen only draws this state and calls these actions.\n//\n// Start protocol: the host writes `start = <mode>` into its member state (for late joiners and for a\n// guest who missed the message) and sends a start message over the links the lobby already opened\n// (instant, before any game message on the ordered channel). Every device then switches to the game's\n// mode and hands the room to the game's `start`. From that moment the game owns the channels.\n\nexport interface ChatLine {\n from: string;\n text: string;\n at: number;\n}\n\nexport interface RoomsState {\n session: RealtimeSession | null;\n /** The private room's code — known to its creator (to share it) and to whoever typed it. */\n joinCode: string | null;\n /** The game the room went into (null — still in the lobby). */\n started: string | null;\n chat: ChatLine[];\n /** A problem to show (a game the app does not have, a start that failed). */\n error: string | null;\n}\n\nexport interface RoomsDeps {\n client: IDosGamesClient;\n games: MultiplayerRegistry;\n navigate: (modeId: string) => void;\n /**\n * The app's loading screen (`ctx.loading`): it stays up while the game takes the room — a guest\n * waits for the host's world for seconds, and a still screen looked like a frozen app.\n */\n loading?: Pick<LoadingReporter, \"track\">;\n /** Its label, in the player's language. */\n connectingLabel?: string;\n}\n\n/** Test seam: how sessions are opened. */\nexport interface SessionFactory {\n create: typeof RealtimeSession.create;\n join: typeof RealtimeSession.join;\n}\n\nexport interface CreateOptions {\n name: string;\n mode: string;\n maxPlayers: number;\n private: boolean;\n joinCode: string;\n settings: Record<string, string | boolean>;\n}\n\nexport function createRooms(\n deps: RoomsDeps,\n factory: SessionFactory = {\n create: (...a) => RealtimeSession.create(...a),\n join: (...a) => RealtimeSession.join(...a),\n },\n) {\n let state: RoomsState = {\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n };\n const listeners = new Set<() => void>();\n let offs: Array<() => void> = [];\n const set = (patch: Partial<RoomsState>) => {\n state = { ...state, ...patch };\n for (const l of listeners) l();\n };\n\n const reset = () => {\n for (const off of offs.splice(0)) off();\n set({\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n });\n };\n\n /** Hand the room to the game — once per room. */\n const enter = (mode: string, lateJoin: boolean) => {\n const session = state.session;\n if (!session || state.started) return;\n const game = deps.games.getGame(mode);\n if (!game) {\n set({ error: \"noGame\" });\n return;\n }\n const host = session.members.get(session.hostId);\n const room: MultiplayerRoom = {\n session,\n mode,\n isHost: session.isHost,\n settings: settingsFromState(game, host?.State),\n lateJoin,\n leave: () => api.leave(),\n };\n set({ started: mode, error: null });\n deps.navigate(mode);\n // After the switch: the game's scene becomes active on the next render.\n setTimeout(() => {\n const work = Promise.resolve().then(() => game.start(room));\n void (deps.loading?.track(work, deps.connectingLabel) ?? work).catch(() =>\n set({ error: \"startFailed\" }),\n );\n }, 0);\n };\n\n const attach = (session: RealtimeSession, joinCode: string | null) => {\n for (const off of offs.splice(0)) off();\n set({ session, joinCode, started: null, chat: [], error: null });\n offs = [\n session.on(\"message\", ({ from, data }) => {\n if (state.started || from !== session.hostId) return;\n const mode = decodeStart(data);\n if (mode) enter(mode, false);\n }),\n session.on(\"membersUpdated\", () => {\n const mode = startedMode(session.members.get(session.hostId));\n if (mode && !state.started) enter(mode, false);\n else set({});\n }),\n session.on(\"hostChanged\", () => set({})),\n // The room screen shows who this player is linked to — redraw when a link opens or drops.\n session.on(\"peerOpen\", () => set({})),\n session.on(\"peerClose\", () => set({})),\n session.on(\"chat\", ({ from, text }) =>\n set({\n chat: [...state.chat, { from, text, at: Date.now() }].slice(-100),\n }),\n ),\n session.on(\"roomClosed\", () => reset()),\n session.on(\"removed\", () => reset()),\n ];\n };\n\n const api = {\n get: () => state,\n subscribe(listener: () => void) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n\n /** Create a room for a game — this player hosts it. */\n async create(o: CreateOptions): Promise<string | null> {\n const game = deps.games.getGame(o.mode);\n if (!game) return \"noGame\";\n const res = await factory.create(deps.client, o.name, {\n topology: game.topology ?? \"Mesh\",\n chatMode: game.chatMode ?? \"ServerRelay\",\n maxPlayers: o.maxPlayers,\n visibility: o.private ? \"Private\" : \"Public\",\n joinCode: o.private ? o.joinCode : undefined,\n mode: o.mode,\n });\n if (!res.ok) return res.error;\n // The settings live in the host's member state: whoever joins reads them from the snapshot.\n // Saved BEFORE the room is shown: a room whose settings did not save is left at once, instead\n // of a half-made room on screen next to an error toast.\n const saved = await deps.client.multiplayer.setMemberState(\n res.data.roomId,\n undefined,\n settingsToState(o.settings),\n );\n if (!saved.ok) {\n await res.data.leave();\n return saved.error;\n }\n attach(res.data, o.private ? o.joinCode : null);\n await res.data.refreshMembers();\n return null;\n },\n\n /** Join a room; into its game right away when the game is running and lets players in. */\n async join(roomId: string, joinCode?: string): Promise<string | null> {\n const res = await factory.join(\n deps.client,\n roomId,\n joinCode || undefined,\n );\n if (!res.ok) return res.error;\n const session = res.data;\n attach(session, joinCode || null);\n const running = startedMode(session.members.get(session.hostId));\n if (running) {\n if (deps.games.getGame(running)?.joinInProgress) enter(running, true);\n else {\n await api.leave();\n return \"inProgress\";\n }\n }\n return null;\n },\n\n async setReady(ready: boolean): Promise<string | null> {\n const s = state.session;\n if (!s) return null;\n const res = await deps.client.multiplayer.setMemberState(s.roomId, ready);\n if (!res.ok) return res.error;\n // Re-read the room at once: otherwise the player's own tick came with the next refresh, seconds\n // later, and \"Ready\" looked like it did nothing.\n await s.refreshMembers();\n return null;\n },\n\n async sendChat(text: string): Promise<string | null> {\n const s = state.session;\n if (!s || !text.trim()) return null;\n const res = await deps.client.multiplayer.sendChat(s.roomId, text.trim());\n return res.ok ? null : res.error;\n },\n\n /** The host takes the whole room into its game. */\n async start(): Promise<string | null> {\n const s = state.session;\n const mode = s?.mode;\n if (!s || !mode || !s.isHost) return null;\n const host = s.members.get(s.selfId);\n const res = await deps.client.multiplayer.setMemberState(s.roomId, true, {\n ...(host?.State ?? {}),\n [START_KEY]: mode,\n });\n if (!res.ok) return res.error;\n s.broadcast(\"rel\", encodeStart(mode));\n enter(mode, false);\n return null;\n },\n\n /** Back to the running game from the lobby (the room is still on). */\n resume() {\n if (state.started) deps.navigate(state.started);\n },\n\n async kick(userId: string): Promise<string | null> {\n const s = state.session;\n if (!s) return null;\n const res = await s.kick(userId);\n return res.ok ? null : res.error;\n },\n\n async leave(): Promise<void> {\n const s = state.session;\n reset();\n await s?.leave();\n },\n };\n return api;\n}\n\nexport type Rooms = ReturnType<typeof createRooms>;\n"
|
|
87
|
+
"content": "import type { IDosGamesClient } from \"@idosgames/core\";\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\nimport type {\n LoadingReporter,\n MultiplayerRegistry,\n MultiplayerRoom,\n} from \"@idosgames/module-sdk\";\nimport {\n START_KEY,\n decodeStart,\n encodeStart,\n settingsFromState,\n settingsToState,\n startedMode,\n} from \"./model\";\n\n// The player's room, owned by the module rather than by its screen: it has to outlive the screen —\n// the room goes into a game and comes back to the lobby, and the session (with its WebRTC links) is\n// the same all along. The screen only draws this state and calls these actions.\n//\n// Start protocol: the host writes `start = <mode>` into its member state (for late joiners and for a\n// guest who missed the message) and sends a start message over the links the lobby already opened\n// (instant, before any game message on the ordered channel). Every device then switches to the game's\n// mode and hands the room to the game's `start`. From that moment the game owns the channels.\n\nexport interface ChatLine {\n from: string;\n text: string;\n at: number;\n}\n\nexport interface RoomsState {\n session: RealtimeSession | null;\n /** The private room's code — known to its creator (to share it) and to whoever typed it. */\n joinCode: string | null;\n /** The game the room went into (null — still in the lobby). */\n started: string | null;\n chat: ChatLine[];\n /** A problem to show (a game the app does not have, a start that failed). */\n error: string | null;\n}\n\nexport interface RoomsDeps {\n client: IDosGamesClient;\n games: MultiplayerRegistry;\n navigate: (modeId: string) => void;\n /**\n * The app's loading screen (`ctx.loading`): it stays up while the game takes the room — a guest\n * waits for the host's world for seconds, and a still screen looked like a frozen app.\n */\n loading?: Pick<LoadingReporter, \"track\">;\n /** Its label, in the player's language. */\n connectingLabel?: string;\n}\n\n/** Test seam: how sessions are opened. */\nexport interface SessionFactory {\n create: typeof RealtimeSession.create;\n join: typeof RealtimeSession.join;\n}\n\nexport interface CreateOptions {\n name: string;\n mode: string;\n maxPlayers: number;\n private: boolean;\n joinCode: string;\n settings: Record<string, string | boolean>;\n}\n\nexport function createRooms(\n deps: RoomsDeps,\n factory: SessionFactory = {\n create: (...a) => RealtimeSession.create(...a),\n join: (...a) => RealtimeSession.join(...a),\n },\n) {\n let state: RoomsState = {\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n };\n const listeners = new Set<() => void>();\n let offs: Array<() => void> = [];\n /** The settings the game was started with — a successor host writes them back with `start`. */\n let startedSettings: Record<string, string | boolean> = {};\n const set = (patch: Partial<RoomsState>) => {\n state = { ...state, ...patch };\n for (const l of listeners) l();\n };\n\n const reset = () => {\n for (const off of offs.splice(0)) off();\n set({\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n });\n };\n\n /** Hand the room to the game — once per room. */\n const enter = (mode: string, lateJoin: boolean) => {\n const session = state.session;\n if (!session || state.started) return;\n const game = deps.games.getGame(mode);\n if (!game) {\n set({ error: \"noGame\" });\n return;\n }\n const host = session.members.get(session.hostId);\n const room: MultiplayerRoom = {\n session,\n mode,\n isHost: session.isHost,\n settings: settingsFromState(game, host?.State),\n lateJoin,\n leave: () => api.leave(),\n };\n startedSettings = room.settings;\n set({ started: mode, error: null });\n deps.navigate(mode);\n // After the switch: the game's scene becomes active on the next render.\n setTimeout(() => {\n const work = Promise.resolve().then(() => game.start(room));\n void (deps.loading?.track(work, deps.connectingLabel) ?? work).catch(() =>\n set({ error: \"startFailed\" }),\n );\n }, 0);\n };\n\n const attach = (session: RealtimeSession, joinCode: string | null) => {\n for (const off of offs.splice(0)) off();\n set({ session, joinCode, started: null, chat: [], error: null });\n offs = [\n session.on(\"message\", ({ from, data }) => {\n if (state.started || from !== session.hostId) return;\n const mode = decodeStart(data);\n if (mode) enter(mode, false);\n }),\n session.on(\"membersUpdated\", () => {\n const mode = startedMode(session.members.get(session.hostId));\n if (mode && !state.started) enter(mode, false);\n else set({});\n }),\n session.on(\"hostChanged\", () => {\n set({});\n // \"The game is running\" lives in the HOST's member state. The game moved the world to this\n // player, but the mark stayed with the old host: whoever came back saw \"the host starts the\n // game\" forever, and nobody had a \"Play\" button. The successor carries the mark on.\n if (session.isHost && state.started)\n void deps.client.multiplayer\n .setMemberState(session.roomId, true, {\n ...settingsToState(startedSettings),\n [START_KEY]: state.started,\n })\n .then((res) => (res.ok ? session.refreshMembers() : undefined));\n }),\n // The room screen shows who this player is linked to — redraw when a link opens or drops.\n session.on(\"peerOpen\", () => set({})),\n session.on(\"peerClose\", () => set({})),\n session.on(\"chat\", ({ from, text }) =>\n set({\n chat: [...state.chat, { from, text, at: Date.now() }].slice(-100),\n }),\n ),\n session.on(\"roomClosed\", () => reset()),\n session.on(\"removed\", () => reset()),\n ];\n };\n\n const api = {\n get: () => state,\n subscribe(listener: () => void) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n\n /** Create a room for a game — this player hosts it. */\n async create(o: CreateOptions): Promise<string | null> {\n const game = deps.games.getGame(o.mode);\n if (!game) return \"noGame\";\n const res = await factory.create(deps.client, o.name, {\n topology: game.topology ?? \"Mesh\",\n chatMode: game.chatMode ?? \"ServerRelay\",\n maxPlayers: o.maxPlayers,\n visibility: o.private ? \"Private\" : \"Public\",\n joinCode: o.private ? o.joinCode : undefined,\n mode: o.mode,\n });\n if (!res.ok) return res.error;\n // The settings live in the host's member state: whoever joins reads them from the snapshot.\n // Saved BEFORE the room is shown: a room whose settings did not save is left at once, instead\n // of a half-made room on screen next to an error toast.\n const saved = await deps.client.multiplayer.setMemberState(\n res.data.roomId,\n undefined,\n settingsToState(o.settings),\n );\n if (!saved.ok) {\n await res.data.leave();\n return saved.error;\n }\n attach(res.data, o.private ? o.joinCode : null);\n await res.data.refreshMembers();\n return null;\n },\n\n /** Join a room; into its game right away when the game is running and lets players in. */\n async join(roomId: string, joinCode?: string): Promise<string | null> {\n const res = await factory.join(\n deps.client,\n roomId,\n joinCode || undefined,\n );\n if (!res.ok) return res.error;\n const session = res.data;\n attach(session, joinCode || null);\n const running = startedMode(session.members.get(session.hostId));\n if (running) {\n if (deps.games.getGame(running)?.joinInProgress) enter(running, true);\n else {\n await api.leave();\n return \"inProgress\";\n }\n }\n return null;\n },\n\n async setReady(ready: boolean): Promise<string | null> {\n const s = state.session;\n if (!s) return null;\n const res = await deps.client.multiplayer.setMemberState(s.roomId, ready);\n if (!res.ok) return res.error;\n // Re-read the room at once: otherwise the player's own tick came with the next refresh, seconds\n // later, and \"Ready\" looked like it did nothing.\n await s.refreshMembers();\n return null;\n },\n\n async sendChat(text: string): Promise<string | null> {\n const s = state.session;\n if (!s || !text.trim()) return null;\n const res = await deps.client.multiplayer.sendChat(s.roomId, text.trim());\n return res.ok ? null : res.error;\n },\n\n /** The host takes the whole room into its game. */\n async start(): Promise<string | null> {\n const s = state.session;\n const mode = s?.mode;\n if (!s || !mode || !s.isHost) return null;\n const host = s.members.get(s.selfId);\n const res = await deps.client.multiplayer.setMemberState(s.roomId, true, {\n ...(host?.State ?? {}),\n [START_KEY]: mode,\n });\n if (!res.ok) return res.error;\n s.broadcast(\"rel\", encodeStart(mode));\n enter(mode, false);\n return null;\n },\n\n /** Back to the running game from the lobby (the room is still on). */\n resume() {\n if (state.started) deps.navigate(state.started);\n },\n\n async kick(userId: string): Promise<string | null> {\n const s = state.session;\n if (!s) return null;\n const res = await s.kick(userId);\n return res.ok ? null : res.error;\n },\n\n async leave(): Promise<void> {\n const s = state.session;\n reset();\n await s?.leave();\n },\n };\n return api;\n}\n\nexport type Rooms = ReturnType<typeof createRooms>;\n"
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
"path": "types.ts",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
},
|
|
93
93
|
{
|
|
94
94
|
"path": "module.meta.json",
|
|
95
|
-
"content": "{\n \"id\": \"multiplayer\",\n \"type\": \"feature\",\n \"summary\": \"The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready and chat — the host takes the whole room into a game.\",\n \"description\": \"A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.\",\n \"provides\": [\n \"multiplayer rooms lobby\",\n \"rooms of one game\",\n \"public and private rooms by invite\",\n \"room settings of the game\",\n \"room members with ready and host\",\n \"room chat\",\n \"host starts the game for the whole room\",\n \"late joiners into a running game\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
95
|
+
"content": "{\n \"id\": \"multiplayer\",\n \"type\": \"feature\",\n \"summary\": \"The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready and chat — the host takes the whole room into a game.\",\n \"description\": \"A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.\",\n \"provides\": [\n \"multiplayer rooms lobby\",\n \"rooms of one game\",\n \"public and private rooms by invite\",\n \"room settings of the game\",\n \"room members with ready and host\",\n \"room chat\",\n \"host starts the game for the whole room\",\n \"late joiners into a running game\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
|
|
96
96
|
}
|
|
97
97
|
]
|
|
98
98
|
}
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@idosgames/core": "0.21.6",
|
|
36
36
|
"@idosgames/module-sdk": "0.11.0",
|
|
37
|
-
"@idosgames/react": "0.5.
|
|
37
|
+
"@idosgames/react": "0.5.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "fc37c3801909ed96833f1c07e64c659ca174de8667720d0f19f43f3613e754ac",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@idosgames/core": "0.21.6",
|
|
38
38
|
"@idosgames/module-sdk": "0.11.0",
|
|
39
|
-
"@idosgames/react": "0.5.
|
|
39
|
+
"@idosgames/react": "0.5.1",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "374cc0e7260f5286c9195509a1879fd6b58cabcec3411c48fe5611abf696fe00",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@idosgames/core": "0.21.6",
|
|
37
37
|
"@idosgames/module-sdk": "0.11.0",
|
|
38
|
-
"@idosgames/react": "0.5.
|
|
38
|
+
"@idosgames/react": "0.5.1",
|
|
39
39
|
"react": "19.2.7"
|
|
40
40
|
},
|
|
41
41
|
"contentHash": "bf7c4ec82041941ef2251c1fb5d4b00633ca81b80321020ee03dccd395f98d51",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@idosgames/core": "0.21.6",
|
|
38
38
|
"@idosgames/module-sdk": "0.11.0",
|
|
39
|
-
"@idosgames/react": "0.5.
|
|
39
|
+
"@idosgames/react": "0.5.1",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "a0ca5f4e1facf2ae8c7728ac2cd24d2b52687ec1ebef06115d0200e8ff4cb20f",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@idosgames/core": "0.21.6",
|
|
35
35
|
"@idosgames/module-sdk": "0.11.0",
|
|
36
|
-
"@idosgames/react": "0.5.
|
|
36
|
+
"@idosgames/react": "0.5.1",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
39
|
"contentHash": "70fde2f88bc77f2c1006bea7e4dce11246951aad4166d8986c32c1acc4a80e4c",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@idosgames/core": "0.21.6",
|
|
40
40
|
"@idosgames/module-sdk": "0.11.0",
|
|
41
|
-
"@idosgames/react": "0.5.
|
|
41
|
+
"@idosgames/react": "0.5.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "25ac34032e14fd679c08100e3d8c513f13f3a85b3b021f20f628e627a11b2c8a",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@idosgames/core": "0.21.6",
|
|
40
40
|
"@idosgames/module-sdk": "0.11.0",
|
|
41
|
-
"@idosgames/react": "0.5.
|
|
41
|
+
"@idosgames/react": "0.5.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "8442dbfd3b218dc5b93e890604811b5fd9df2893ae492a52a0a37b035ed804d1",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@idosgames/core": "0.21.6",
|
|
36
36
|
"@idosgames/module-sdk": "0.11.0",
|
|
37
|
-
"@idosgames/react": "0.5.
|
|
37
|
+
"@idosgames/react": "0.5.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "86cdde5c496c220eda1de6c970c871421b61aca9aefb605227075895a8adf5cf",
|