@idosgames/mcp 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +5 -5
  4. package/registry/index.json +124 -120
  5. package/registry/modules/ai.json +3 -3
  6. package/registry/modules/board-game.json +37 -37
  7. package/registry/modules/boosts.json +3 -3
  8. package/registry/modules/character.json +15 -15
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +3 -3
  11. package/registry/modules/coop-events.json +3 -3
  12. package/registry/modules/craft.json +3 -3
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +8 -8
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +3 -3
  18. package/registry/modules/invite.json +3 -3
  19. package/registry/modules/leaderboard.json +12 -12
  20. package/registry/modules/lootboxes.json +8 -8
  21. package/registry/modules/mailbox.json +3 -3
  22. package/registry/modules/marketplace.json +3 -3
  23. package/registry/modules/mini-game.json +7 -7
  24. package/registry/modules/multiplayer.json +28 -16
  25. package/registry/modules/premium.json +3 -3
  26. package/registry/modules/profile.json +3 -3
  27. package/registry/modules/pvp.json +3 -3
  28. package/registry/modules/quests.json +10 -10
  29. package/registry/modules/rewards.json +3 -3
  30. package/registry/modules/season.json +3 -3
  31. package/registry/modules/store.json +3 -3
  32. package/registry/modules/tutorials.json +3 -3
  33. package/registry/modules/voxelcraft.json +13 -13
  34. package/registry/modules/workshop.json +3 -3
  35. package/registry/skills/authentication.json +1 -1
  36. package/registry/skills/idosgames-module-contract.json +1 -1
  37. package/registry/skills/leaderboard-system.json +2 -2
  38. package/registry/skills/multiplayer-realtime.json +1 -1
@@ -29,19 +29,19 @@
29
29
  "name": "iDos Games",
30
30
  "url": "https://idosgames.com"
31
31
  },
32
- "version": "0.1.1"
32
+ "version": "0.1.2"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.21.6",
36
- "@idosgames/module-sdk": "0.11.0",
37
- "@idosgames/react": "0.5.1",
35
+ "@idosgames/core": "0.22.1",
36
+ "@idosgames/module-sdk": "0.12.0",
37
+ "@idosgames/react": "0.5.2",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "44d612ef23e30ce667db14ffd24b82fe6076efafad2e56d5c0d86613610059e2",
40
+ "contentHash": "5be13857ce50d58ef706d89709dd31ece307812b1072f876a63754c080d33cec",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
44
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Chests\",\n open: \"Open\",\n openTimes: \"Open\",\n opening: \"Opening…\",\n noChests: \"No chests in this game yet. Add them in LiveOps → Lootboxes.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Сундуки\",\n open: \"Открыть\",\n openTimes: \"Открыть\",\n opening: \"Открываем…\",\n noChests: \"Сундуков пока нет. Добавьте их в LiveOps → Lootboxes.\",\n});\n"
44
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Chests\",\n open: \"Open\",\n openTimes: \"Open\",\n opening: \"Opening…\",\n tapNow: \"Tap it now!\",\n noChests: \"No chests in this game yet. Add them in LiveOps → Lootboxes.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Сундуки\",\n open: \"Открыть\",\n openTimes: \"Открыть\",\n opening: \"Открываем…\",\n tapNow: \"Жми сейчас!\",\n noChests: \"Сундуков пока нет. Добавьте их в LiveOps → Lootboxes.\",\n});\n"
45
45
  },
46
46
  {
47
47
  "path": "index.ts",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  {
51
51
  "path": "LootboxesScreen.tsx",
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"
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 KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\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} fallback=\"chest\" />\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/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\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<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\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 ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\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 <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n backgroundColor: \"#3a2fd0\",\n backgroundImage: `repeating-linear-gradient(135deg, rgba(128,230,255,.15) 0 14px, rgba(128,230,255,0) 14px 28px), radial-gradient(ellipse at 50% 45%, #6154ff 0%, #3a2fd0 75%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(115,242,255,.45)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n filter: \"drop-shadow(0 10px 0 rgba(0,0,0,.3))\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background:\n \"radial-gradient(circle at 50% 35%, #ff6070 0%, #ff243d 70%)\",\n border: \"3px solid #540a10\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\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"
53
53
  },
54
54
  {
55
55
  "path": "model.ts",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
64
- "content": "{\n \"id\": \"lootboxes\",\n \"type\": \"feature\",\n \"summary\": \"Chests for any game: the title's lootboxes with every way to open them, once or ten at a time, with an opening animation.\",\n \"description\": \"A ready chests screen (Unity LootboxOpeningPopUp) for ANY game, built on the @idosgames/react/ui kit and client.lootbox. Every lootbox of the title as a card for each way to open it (currencies, a chest item, a key); 'Open' once, and 'Open ×10' where the lootbox's ceiling (MaxOpenCount) and the player's balance allow — the box shakes while the server rolls it, bursts, and the rewards fly out. The inventory hands a chest or key item over (ctx.features.open('lootboxes', { itemID })) — the screen then shows what that item opens. Registers the 'lootboxes' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"lootbox list with every way to open\",\n \"opening animation with rewards\",\n \"open ten at once\",\n \"open a chest with a key item\",\n \"badge for openable chests\"\n ],\n \"tags\": [\"lootbox\", \"chest\", \"gacha\", \"rewards\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
64
+ "content": "{\n \"id\": \"lootboxes\",\n \"type\": \"feature\",\n \"summary\": \"Chests for any game: the title's lootboxes with every way to open them, once or ten at a time, with an opening animation.\",\n \"description\": \"A ready chests screen (Unity LootboxOpeningPopUp) for ANY game, built on the @idosgames/react/ui kit and client.lootbox. Every lootbox of the title as a card for each way to open it (currencies, a chest item, a key); 'Open' once, and 'Open ×10' where the lootbox's ceiling (MaxOpenCount) and the player's balance allow — the box shakes while the server rolls it, bursts, and the rewards fly out. The inventory hands a chest or key item over (ctx.features.open('lootboxes', { itemID })) — the screen then shows what that item opens. Registers the 'lootboxes' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"lootbox list with every way to open\",\n \"opening animation with rewards\",\n \"open ten at once\",\n \"open a chest with a key item\",\n \"badge for openable chests\"\n ],\n \"tags\": [\"lootbox\", \"chest\", \"gacha\", \"rewards\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
65
65
  }
66
66
  ]
67
67
  }
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.1"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.21.6",
40
- "@idosgames/module-sdk": "0.11.0",
41
- "@idosgames/react": "0.5.1",
39
+ "@idosgames/core": "0.22.1",
40
+ "@idosgames/module-sdk": "0.12.0",
41
+ "@idosgames/react": "0.5.2",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "6863f9fdc8aa3520038c623143ed8b3905c8ab7ae4f4498ab999236732e9e60c",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.2"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.21.6",
40
- "@idosgames/module-sdk": "0.11.0",
41
- "@idosgames/react": "0.5.1",
39
+ "@idosgames/core": "0.22.1",
40
+ "@idosgames/module-sdk": "0.12.0",
41
+ "@idosgames/react": "0.5.2",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "9c279916de34ee33f0993ebc3f73dec8885a343110e6ad36f3c984a909b45060",
@@ -27,15 +27,15 @@
27
27
  "name": "iDos Games",
28
28
  "url": "https://idosgames.com"
29
29
  },
30
- "version": "0.1.1"
30
+ "version": "0.1.2"
31
31
  },
32
32
  "dependencies": {
33
- "@idosgames/core": "0.21.6",
34
- "@idosgames/module-sdk": "0.11.0",
35
- "@idosgames/react": "0.5.1",
33
+ "@idosgames/core": "0.22.1",
34
+ "@idosgames/module-sdk": "0.12.0",
35
+ "@idosgames/react": "0.5.2",
36
36
  "react": "19.2.7"
37
37
  },
38
- "contentHash": "eb39139d93bfb84e5a20d2485b081a264234452b5d90978fdf15b940cf3b7ac0",
38
+ "contentHash": "a8a2f326ba9e50a77ca3938838e9f83b2d6afd6b92c7a0570ff7967b7ed26745",
39
39
  "files": [
40
40
  {
41
41
  "path": "i18n.ts",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  {
57
57
  "path": "model.ts",
58
- "content": "// Pure helpers of the mini-game — covered by model.test.ts.\n\n/** Metric the mini-game reports once per finished round. A quest counts it only if it asks for it. */\nexport const GAMES_PLAYED_METRIC = \"games_played\";\n\n/** Leaderboard the mini-game submits to when the title has it; otherwise the first enabled one. */\nexport const PREFERRED_LEADERBOARD = \"weekly_score\";\n\nexport interface LeaderboardSection {\n Definitions?: Record<\n string,\n {\n LeaderboardID?: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n ScoreSources?: unknown[] | null;\n } | null\n > | null;\n}\n\n/**\n * The leaderboard the mini-game plays for: the preferred id if enabled, else the first enabled one.\n * A board fed by its own sources (invites, purchases…) is not a score table — the server fills it\n * and refuses a submitted score, so the mini-game never picks it.\n */\nexport function pickLeaderboard(\n section: LeaderboardSection | undefined,\n): { LeaderboardID: string; DisplayName?: string | null } | null {\n const defs = Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (\n d,\n ): d is {\n LeaderboardID: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n } => d !== null && d.IsEnabled !== false,\n )\n .filter(\n (d) =>\n ((d as { ScoreSources?: unknown[] | null }).ScoreSources ?? [])\n .length === 0,\n );\n return (\n defs.find((d) => d.LeaderboardID === PREFERRED_LEADERBOARD) ??\n defs.sort((a, b) => a.LeaderboardID.localeCompare(b.LeaderboardID))[0] ??\n null\n );\n}\n\nexport interface QuestSection {\n Quests?: Record<\n string,\n {\n Objectives?: Record<\n string,\n { MetricID?: string | null; Source?: string } | null\n > | null;\n } | null\n > | null;\n}\n\n/** Whether some quest counts the mini-game's rounds — only then is the metric worth reporting. */\nexport function questsCountGames(section: QuestSection | undefined): boolean {\n return Object.values(section?.Quests ?? {}).some((q) =>\n Object.values(q?.Objectives ?? {}).some(\n (o) =>\n o?.MetricID === GAMES_PLAYED_METRIC &&\n (o.Source ?? \"ClientApi\") === \"ClientApi\",\n ),\n );\n}\n\n// ── A round feeds the title's events and seasons ─────────────────────────────────────────────\n// Found in the config, so the mini-game needs neither the events nor the season module installed.\n\n/** The CustomAction a round of the mini-game reports to timed events. */\nexport const PLAY_ROUND_ACTION = \"play_round\";\n/** Status a round grants to every season chain open to clients. */\nexport const ROUND_STATUS = 10;\n\nexport interface TimedEventSection {\n Definitions?: Record<\n string,\n {\n Content?: EventContentView | null;\n Events?: Array<{ Content?: EventContentView | null } | null> | null;\n } | null\n > | null;\n}\ntype EventContentView = {\n TokenSources?: Array<{\n SourceType?: string | null;\n Params?: Record<string, string> | null;\n } | null> | null;\n};\n\nfunction countsRounds(content: EventContentView | null | undefined): boolean {\n return (content?.TokenSources ?? []).some(\n (s) =>\n s?.SourceType === \"CustomAction\" &&\n s.Params?.ActionName === PLAY_ROUND_ACTION,\n );\n}\n\n/** Events (by id) whose token sources pay for a round — own content or any phase of a chain. */\nexport function eventsCountingRounds(\n section: TimedEventSection | undefined,\n): string[] {\n return Object.entries(section?.Definitions ?? {})\n .filter(\n ([, d]) =>\n d &&\n (countsRounds(d.Content) ||\n (d.Events ?? []).some((p) => countsRounds(p?.Content))),\n )\n .map(([id]) => id);\n}\n\nexport interface SeasonSection {\n Chains?: Record<\n string,\n { GrantTokensAccessMode?: string | null } | null\n > | null;\n}\n\n/**\n * Season chains the client may grant status to. Only an explicit \"Both\" / \"ClientOnly\" opens a\n * chain: the engine's default is ServerOnly, so a missing field means \"server only\" too.\n */\nexport function seasonChainsForRounds(\n section: SeasonSection | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(\n ([, c]) =>\n c?.GrantTokensAccessMode === \"Both\" ||\n c?.GrantTokensAccessMode === \"ClientOnly\",\n )\n .map(([id]) => id);\n}\n"
58
+ "content": "// Pure helpers of the mini-game — covered by model.test.ts.\n\n/** Metric the mini-game reports once per finished round. A quest counts it only if it asks for it. */\nexport const GAMES_PLAYED_METRIC = \"games_played\";\n\n/** Leaderboard the mini-game submits to when the title has it; otherwise the first enabled one. */\nexport const PREFERRED_LEADERBOARD = \"weekly_score\";\n\nexport interface LeaderboardSection {\n Definitions?: Record<\n string,\n {\n LeaderboardID?: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n } | null\n > | null;\n}\n\n/**\n * The leaderboard the mini-game plays for: the preferred id if enabled, else the first enabled one.\n * Only a board that takes a score from the game — one with a `ClientScore` score source: the server\n * refuses a submitted score into any other board (one it fills itself, or one not set up yet).\n */\nexport function pickLeaderboard(\n section: LeaderboardSection | undefined,\n): { LeaderboardID: string; DisplayName?: string | null } | null {\n const defs = Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (\n d,\n ): d is {\n LeaderboardID: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n } => d !== null && d.IsEnabled !== false,\n )\n .filter((d) =>\n (\n (\n d as {\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n }\n ).ScoreSources ?? []\n ).some((s) => s?.SourceType === \"ClientScore\"),\n );\n return (\n defs.find((d) => d.LeaderboardID === PREFERRED_LEADERBOARD) ??\n defs.sort((a, b) => a.LeaderboardID.localeCompare(b.LeaderboardID))[0] ??\n null\n );\n}\n\nexport interface QuestSection {\n Quests?: Record<\n string,\n {\n Objectives?: Record<\n string,\n { MetricID?: string | null; Source?: string } | null\n > | null;\n } | null\n > | null;\n}\n\n/** Whether some quest counts the mini-game's rounds — only then is the metric worth reporting. */\nexport function questsCountGames(section: QuestSection | undefined): boolean {\n return Object.values(section?.Quests ?? {}).some((q) =>\n Object.values(q?.Objectives ?? {}).some(\n (o) =>\n o?.MetricID === GAMES_PLAYED_METRIC &&\n (o.Source ?? \"ClientApi\") === \"ClientApi\",\n ),\n );\n}\n\n// ── A round feeds the title's events and seasons ─────────────────────────────────────────────\n// Found in the config, so the mini-game needs neither the events nor the season module installed.\n\n/** The CustomAction a round of the mini-game reports to timed events. */\nexport const PLAY_ROUND_ACTION = \"play_round\";\n/** Status a round grants to every season chain open to clients. */\nexport const ROUND_STATUS = 10;\n\nexport interface TimedEventSection {\n Definitions?: Record<\n string,\n {\n Content?: EventContentView | null;\n Events?: Array<{ Content?: EventContentView | null } | null> | null;\n } | null\n > | null;\n}\ntype EventContentView = {\n TokenSources?: Array<{\n SourceType?: string | null;\n Params?: Record<string, string> | null;\n } | null> | null;\n};\n\nfunction countsRounds(content: EventContentView | null | undefined): boolean {\n return (content?.TokenSources ?? []).some(\n (s) =>\n s?.SourceType === \"CustomAction\" &&\n s.Params?.ActionName === PLAY_ROUND_ACTION,\n );\n}\n\n/** Events (by id) whose token sources pay for a round — own content or any phase of a chain. */\nexport function eventsCountingRounds(\n section: TimedEventSection | undefined,\n): string[] {\n return Object.entries(section?.Definitions ?? {})\n .filter(\n ([, d]) =>\n d &&\n (countsRounds(d.Content) ||\n (d.Events ?? []).some((p) => countsRounds(p?.Content))),\n )\n .map(([id]) => id);\n}\n\nexport interface SeasonSection {\n Chains?: Record<\n string,\n { GrantTokensAccessMode?: string | null } | null\n > | null;\n}\n\n/**\n * Season chains the client may grant status to. Only an explicit \"Both\" / \"ClientOnly\" opens a\n * chain: the engine's default is ServerOnly, so a missing field means \"server only\" too.\n */\nexport function seasonChainsForRounds(\n section: SeasonSection | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(\n ([, c]) =>\n c?.GrantTokensAccessMode === \"Both\" ||\n c?.GrantTokensAccessMode === \"ClientOnly\",\n )\n .map(([id]) => id);\n}\n"
59
59
  },
60
60
  {
61
61
  "path": "module.ts",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",
66
- "content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
66
+ "content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
67
67
  }
68
68
  ]
69
69
  }
@@ -7,8 +7,8 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
- "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.",
11
- "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.",
10
+ "summary": "The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready, chat and voice chat — the host takes the whole room into a game.",
11
+ "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), voice chat (mic off / push-to-talk on V / open mic, every member's mute and volume, who is speaking, a mic button over the running game, the \"Conversations may be recorded\" notice; the game reads room.voice for talking markers or proximity volume), 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.",
12
12
  "provides": [
13
13
  "multiplayer rooms lobby",
14
14
  "rooms of one game",
@@ -17,7 +17,10 @@
17
17
  "room members with ready and host",
18
18
  "room chat",
19
19
  "host starts the game for the whole room",
20
- "late joiners into a running game"
20
+ "late joiners into a running game",
21
+ "voice chat in rooms",
22
+ "push-to-talk",
23
+ "who is speaking"
21
24
  ],
22
25
  "tags": [
23
26
  "multiplayer",
@@ -26,21 +29,22 @@
26
29
  "realtime",
27
30
  "social",
28
31
  "ui",
29
- "screen"
32
+ "screen",
33
+ "voice"
30
34
  ],
31
35
  "author": {
32
36
  "name": "iDos Games",
33
37
  "url": "https://idosgames.com"
34
38
  },
35
- "version": "0.1.4"
39
+ "version": "0.2.0"
36
40
  },
37
41
  "dependencies": {
38
- "@idosgames/core": "0.21.6",
39
- "@idosgames/module-sdk": "0.11.0",
40
- "@idosgames/react": "0.5.1",
42
+ "@idosgames/core": "0.22.1",
43
+ "@idosgames/module-sdk": "0.12.0",
44
+ "@idosgames/react": "0.5.2",
41
45
  "react": "19.2.7"
42
46
  },
43
- "contentHash": "99fdaee28901b7e53a81d3101ae69c760f8d93af56cd438378afc791c7782938",
47
+ "contentHash": "852efbc8a91a227ca32230c121bdab8f12f4a5725bc9ade994dd05c15479a5f5",
44
48
  "files": [
45
49
  {
46
50
  "path": "components/CodeForm.tsx",
@@ -48,23 +52,27 @@
48
52
  },
49
53
  {
50
54
  "path": "components/CreateForm.tsx",
51
- "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type MultiplayerGame } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n TextInput,\n usePopupClose,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { defaultSettings, newCode } from \"../model\";\nimport { t } from \"../i18n\";\nimport { gameLabel } from \"../helpers\";\nimport { type ScreenDeps } from \"../types\";\n\nexport function CreateForm({\n deps,\n games,\n preset,\n}: {\n deps: ScreenDeps;\n games: MultiplayerGame[];\n preset: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [mode, setMode] = useState(\n games.find((g) => g.mode === preset)?.mode ?? games[0]?.mode ?? \"\",\n );\n const game = games.find((g) => g.mode === mode);\n const cap = Math.min(\n Number(configSection(client, \"Multiplayer\")?.MaxPlayersPerRoom ?? 8),\n game?.maxPlayers ?? 8,\n );\n const sizes = [2, 4, 8].filter((n) => n <= cap);\n const [name, setName] = useState(\"\");\n const [max, setMax] = useState(sizes[sizes.length - 1] ?? 2);\n const [priv, setPriv] = useState(false);\n const [settings, setSettings] = useState(() => defaultSettings(game));\n const [busy, setBusy] = useState(false);\n useEffect(() => setSettings(defaultSettings(game)), [mode]);\n\n if (!game) return <EmptyState glyph=\"globe\" text={t(\"noGames\")} />;\n\n const create = async () => {\n setBusy(true);\n const error = await deps.rooms.create({\n name: name.trim() || t(\"room\"),\n mode,\n maxPlayers: max,\n private: priv,\n joinCode: newCode(),\n settings,\n });\n setBusy(false);\n if (error) return toast(errorText(error), \"error\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n {games.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {games.map((g) => (\n <Button\n key={g.mode}\n size=\"sm\"\n tone={g.mode === mode ? \"gold\" : \"grey\"}\n onClick={() => setMode(g.mode)}\n >\n {gameLabel(g, deps.modes, g.mode)}\n </Button>\n ))}\n </div>\n ) : null}\n <TextInput\n value={name}\n maxLength={40}\n placeholder={t(\"roomName\")}\n onChange={(e) => setName(e.target.value)}\n style={{ padding: \"10px 12px\", width: \"100%\" }}\n />\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n {t(\"players\")}:\n {sizes.map((n) => (\n <Button\n key={n}\n size=\"sm\"\n tone={max === n ? \"gold\" : \"grey\"}\n onClick={() => setMax(n)}\n >\n {n}\n </Button>\n ))}\n </div>\n <Button\n tone={priv ? \"gold\" : \"grey\"}\n size=\"sm\"\n onClick={() => setPriv(!priv)}\n >\n <Icon glyph=\"lock\" size={16} /> {priv ? t(\"private\") : t(\"public\")}\n </Button>\n {(game.settings ?? []).map((field) =>\n field.kind === \"toggle\" ? (\n <Button\n key={field.id}\n size=\"sm\"\n tone={settings[field.id] === true ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({\n ...settings,\n [field.id]: settings[field.id] !== true,\n })\n }\n >\n {settings[field.id] === true ? \"✓ \" : \"\"}\n {field.label}\n </Button>\n ) : (\n <div key={field.id} style={{ display: \"grid\", gap: 6 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {field.label}\n </span>\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {(field.options ?? []).map((o) => (\n <Button\n key={o.value}\n size=\"sm\"\n tone={settings[field.id] === o.value ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({ ...settings, [field.id]: o.value })\n }\n >\n {o.label}\n </Button>\n ))}\n </div>\n </div>\n ),\n )}\n <Button tone=\"green\" busy={busy} onClick={() => void create()}>\n {t(\"create\")}\n </Button>\n </div>\n );\n}\n"
55
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type MultiplayerGame } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n TextInput,\n usePopupClose,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { defaultSettings, newCode } from \"../model\";\nimport { t } from \"../i18n\";\nimport { gameLabel } from \"../helpers\";\nimport { type ScreenDeps } from \"../types\";\n\nexport function CreateForm({\n deps,\n games,\n preset,\n}: {\n deps: ScreenDeps;\n games: MultiplayerGame[];\n preset: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [mode, setMode] = useState(\n games.find((g) => g.mode === preset)?.mode ?? games[0]?.mode ?? \"\",\n );\n const game = games.find((g) => g.mode === mode);\n const cap = Math.min(\n Number(configSection(client, \"Multiplayer\")?.MaxPlayersPerRoom ?? 8),\n game?.maxPlayers ?? 8,\n );\n const sizes = [2, 4, 8].filter((n) => n <= cap);\n const [name, setName] = useState(\"\");\n const [max, setMax] = useState(sizes[sizes.length - 1] ?? 2);\n const [priv, setPriv] = useState(false);\n const [settings, setSettings] = useState(() => defaultSettings(game));\n const [busy, setBusy] = useState(false);\n // Voice is on unless the title turned it off; the creator may switch it off for this room.\n const titleVoice =\n configSection(client, \"Multiplayer\")?.VoiceEnabled !== false;\n const [voice, setVoice] = useState(true);\n useEffect(() => setSettings(defaultSettings(game)), [mode]);\n\n if (!game) return <EmptyState glyph=\"globe\" text={t(\"noGames\")} />;\n\n const create = async () => {\n setBusy(true);\n const error = await deps.rooms.create({\n name: name.trim() || t(\"room\"),\n mode,\n maxPlayers: max,\n private: priv,\n joinCode: newCode(),\n settings,\n voice: titleVoice && voice,\n });\n setBusy(false);\n if (error) return toast(errorText(error), \"error\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n {games.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {games.map((g) => (\n <Button\n key={g.mode}\n size=\"sm\"\n tone={g.mode === mode ? \"gold\" : \"grey\"}\n onClick={() => setMode(g.mode)}\n >\n {gameLabel(g, deps.modes, g.mode)}\n </Button>\n ))}\n </div>\n ) : null}\n <TextInput\n value={name}\n maxLength={40}\n placeholder={t(\"roomName\")}\n onChange={(e) => setName(e.target.value)}\n style={{ padding: \"10px 12px\", width: \"100%\" }}\n />\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n {t(\"players\")}:\n {sizes.map((n) => (\n <Button\n key={n}\n size=\"sm\"\n tone={max === n ? \"gold\" : \"grey\"}\n onClick={() => setMax(n)}\n >\n {n}\n </Button>\n ))}\n </div>\n <Button\n tone={priv ? \"gold\" : \"grey\"}\n size=\"sm\"\n onClick={() => setPriv(!priv)}\n >\n <Icon glyph=\"lock\" size={16} /> {priv ? t(\"private\") : t(\"public\")}\n </Button>\n {titleVoice ? (\n <Button\n tone={voice ? \"gold\" : \"grey\"}\n size=\"sm\"\n onClick={() => setVoice(!voice)}\n >\n {voice ? \"🎙 \" : \"\"}\n {voice ? t(\"voiceOn\") : t(\"voiceOff\")}\n </Button>\n ) : null}\n {(game.settings ?? []).map((field) =>\n field.kind === \"toggle\" ? (\n <Button\n key={field.id}\n size=\"sm\"\n tone={settings[field.id] === true ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({\n ...settings,\n [field.id]: settings[field.id] !== true,\n })\n }\n >\n {settings[field.id] === true ? \"✓ \" : \"\"}\n {field.label}\n </Button>\n ) : (\n <div key={field.id} style={{ display: \"grid\", gap: 6 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {field.label}\n </span>\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {(field.options ?? []).map((o) => (\n <Button\n key={o.value}\n size=\"sm\"\n tone={settings[field.id] === o.value ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({ ...settings, [field.id]: o.value })\n }\n >\n {o.label}\n </Button>\n ))}\n </div>\n </div>\n ),\n )}\n <Button tone=\"green\" busy={busy} onClick={() => void create()}>\n {t(\"create\")}\n </Button>\n </div>\n );\n}\n"
52
56
  },
53
57
  {
54
58
  "path": "components/Room.tsx",
55
- "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n configSection,\n errorText,\n Icon,\n outlined,\n panel,\n SectionTitle,\n TextInput,\n useNow,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { memberRows, needsLink, settingsFromState } from \"../model\";\nimport { t } from \"../i18n\";\nimport { gameLabel } from \"../helpers\";\nimport { type ScreenDeps } from \"../types\";\n\n/** How long a guest's link to the host may stay down before the room says it cannot connect. */\nconst NO_LINK_HINT_MS = 15_000;\n\nexport function Room({\n deps,\n onBack,\n}: {\n deps: ScreenDeps;\n /** \"Back to the game\": switch to it and close this screen. */\n onBack: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const now = useNow();\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const session = state.session!;\n const game = deps.games.getGame(session.mode ?? \"\");\n const timeout = Number(\n configSection(client, \"Multiplayer\")?.MemberTimeoutMs ?? 15_000,\n );\n // The heartbeats in the session's member list are as fresh as its last refresh — re-read it while\n // the room is on screen, or after a few seconds everyone looked offline.\n useEffect(() => {\n const id = setInterval(\n () => void session.refreshMembers(),\n Math.max(5_000, timeout / 2),\n );\n return () => clearInterval(id);\n }, [session, timeout]);\n const rows = memberRows(\n session.members.values(),\n session.selfId,\n session.hostId,\n now,\n timeout,\n );\n const me = rows.find((r) => r.me);\n // A guest's link to the host: the game cannot start without it. Down for long — say so here, not\n // as a \"host did not answer\" half a minute after \"Play\".\n const hostRow = rows.find((r) => r.host && !r.me);\n const hostLinkDownSince = useRef<number | null>(null);\n if (!hostRow || session.isOpen(hostRow.userID))\n hostLinkDownSince.current = null;\n else hostLinkDownSince.current ??= now;\n const noLink =\n hostLinkDownSince.current !== null &&\n now - hostLinkDownSince.current > NO_LINK_HINT_MS;\n const nameOf = (id: string) =>\n rows.find((r) => r.userID === id)?.name ?? t(\"player\");\n const settings = settingsFromState(\n game,\n session.members.get(session.hostId)?.State,\n );\n const invite = `${session.roomId}${state.joinCode ? `:${state.joinCode}` : \"\"}`;\n\n const act = async (run: () => Promise<string | null>) => {\n setBusy(true);\n const error = await run();\n setBusy(false);\n if (error) toast(errorText(error), \"error\");\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {gameLabel(game, deps.modes, session.mode)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"players\")}: {rows.length}\n {(game?.settings ?? []).map((f) => {\n const value = settings[f.id];\n const shown =\n f.kind === \"toggle\"\n ? value === true\n ? f.label\n : null\n : `${f.label}: ${f.options?.find((o) => o.value === value)?.label ?? value}`;\n return shown ? ` · ${shown}` : null;\n })}\n </div>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() =>\n void navigator.clipboard\n ?.writeText(invite)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n border: \"none\",\n background: \"none\",\n padding: 0,\n textAlign: \"left\",\n cursor: \"pointer\",\n ...outlined,\n fontSize: 12,\n color: v.gold,\n }}\n >\n <Icon glyph=\"copy\" size={14} /> {t(\"invite\")}: {invite}\n </button>\n </div>\n\n {state.started ? (\n <Button tone=\"gold\" size=\"lg\" attract onClick={onBack}>\n {t(\"backToGame\")}\n </Button>\n ) : null}\n {noLink ? (\n <div style={{ ...outlined, fontSize: 13, color: v.red }}>\n {t(\"noLink\")}\n </div>\n ) : null}\n {state.error ? (\n <div style={{ ...outlined, fontSize: 13, color: v.red }}>\n {t(state.error === \"noGame\" ? \"noGame\" : \"startFailed\")}\n </div>\n ) : null}\n\n <SectionTitle>{t(\"members\")}</SectionTitle>\n {rows.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n opacity: m.online ? 1 : 0.5,\n }}\n >\n <Icon glyph={m.host ? \"crown\" : \"user\"} size={24} />\n <span style={{ ...outlined, fontSize: 14, flex: 1, minWidth: 0 }}>\n {m.me ? `${m.name} (${t(\"you\")})` : m.name}\n {needsLink(session.topology, session.isHost, m) ? (\n <span\n style={{\n display: \"block\",\n fontSize: 11,\n color: session.isOpen(m.userID) ? v.green : v.textDim,\n }}\n >\n {session.isOpen(m.userID) ? t(\"linkOk\") : t(\"linking\")}\n </span>\n ) : null}\n </span>\n {m.ready ? <Icon glyph=\"check\" size={20} /> : null}\n {session.isHost && !m.me && !state.started ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(() => deps.rooms.kick(m.userID))}\n >\n {t(\"kick\")}\n </Button>\n ) : null}\n </Card>\n ))}\n\n {state.started ? null : session.isHost ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n disabled={!game}\n onClick={() => void act(() => deps.rooms.start())}\n data-tutorial-anchor=\"multiplayer:play\"\n >\n {t(\"play\")} · {gameLabel(game, deps.modes, session.mode)}\n </Button>\n ) : (\n <>\n <Button\n tone={me?.ready ? \"grey\" : \"green\"}\n busy={busy}\n onClick={() => void act(() => deps.rooms.setReady(!me?.ready))}\n >\n {me?.ready ? t(\"notReady\") : t(\"ready\")}\n </Button>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"waitHost\")}\n </div>\n </>\n )}\n\n {session.chatMode === \"ServerRelay\" ? (\n <div style={{ ...panel, padding: 10, display: \"grid\", gap: 8 }}>\n <div\n style={{\n display: \"grid\",\n gap: 4,\n maxHeight: 180,\n overflowY: \"auto\",\n }}\n >\n {state.chat.length === 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"noChat\")}\n </div>\n ) : (\n state.chat.map((c, i) => (\n <div key={i} style={{ fontSize: 13, color: v.text }}>\n <b>{nameOf(c.from)}:</b> {c.text}\n </div>\n ))\n )}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <TextInput\n value={text}\n maxLength={200}\n placeholder={t(\"message\")}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => {\n if (e.key !== \"Enter\") return;\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n style={{ padding: \"10px 12px\", flex: 1 }}\n />\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => {\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n >\n {t(\"send\")}\n </Button>\n </div>\n </div>\n ) : null}\n\n <Button tone=\"grey\" size=\"sm\" onClick={() => void deps.rooms.leave()}>\n {t(\"leave\")}\n </Button>\n </div>\n );\n}\n"
59
+ "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n configSection,\n errorText,\n Icon,\n outlined,\n panel,\n SectionTitle,\n TextInput,\n useNow,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { memberRows, needsLink, settingsFromState } from \"../model\";\nimport { t } from \"../i18n\";\nimport { gameLabel } from \"../helpers\";\nimport { type ScreenDeps } from \"../types\";\nimport { MemberVoice, VoiceBar } from \"./Voice\";\n\n/** How long a guest's link to the host may stay down before the room says it cannot connect. */\nconst NO_LINK_HINT_MS = 15_000;\n\nexport function Room({\n deps,\n onBack,\n}: {\n deps: ScreenDeps;\n /** \"Back to the game\": switch to it and close this screen. */\n onBack: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const now = useNow();\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const session = state.session!;\n const game = deps.games.getGame(session.mode ?? \"\");\n const timeout = Number(\n configSection(client, \"Multiplayer\")?.MemberTimeoutMs ?? 15_000,\n );\n // The heartbeats in the session's member list are as fresh as its last refresh — re-read it while\n // the room is on screen, or after a few seconds everyone looked offline.\n useEffect(() => {\n const id = setInterval(\n () => void session.refreshMembers(),\n Math.max(5_000, timeout / 2),\n );\n return () => clearInterval(id);\n }, [session, timeout]);\n const rows = memberRows(\n session.members.values(),\n session.selfId,\n session.hostId,\n now,\n timeout,\n );\n const me = rows.find((r) => r.me);\n // A guest's link to the host: the game cannot start without it. Down for long — say so here, not\n // as a \"host did not answer\" half a minute after \"Play\".\n const hostRow = rows.find((r) => r.host && !r.me);\n const hostLinkDownSince = useRef<number | null>(null);\n if (!hostRow || session.isOpen(hostRow.userID))\n hostLinkDownSince.current = null;\n else hostLinkDownSince.current ??= now;\n const noLink =\n hostLinkDownSince.current !== null &&\n now - hostLinkDownSince.current > NO_LINK_HINT_MS;\n const nameOf = (id: string) =>\n rows.find((r) => r.userID === id)?.name ?? t(\"player\");\n const settings = settingsFromState(\n game,\n session.members.get(session.hostId)?.State,\n );\n const invite = `${session.roomId}${state.joinCode ? `:${state.joinCode}` : \"\"}`;\n\n const act = async (run: () => Promise<string | null>) => {\n setBusy(true);\n const error = await run();\n setBusy(false);\n if (error) toast(errorText(error), \"error\");\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {gameLabel(game, deps.modes, session.mode)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"players\")}: {rows.length}\n {(game?.settings ?? []).map((f) => {\n const value = settings[f.id];\n const shown =\n f.kind === \"toggle\"\n ? value === true\n ? f.label\n : null\n : `${f.label}: ${f.options?.find((o) => o.value === value)?.label ?? value}`;\n return shown ? ` · ${shown}` : null;\n })}\n </div>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() =>\n void navigator.clipboard\n ?.writeText(invite)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n border: \"none\",\n background: \"none\",\n padding: 0,\n textAlign: \"left\",\n cursor: \"pointer\",\n ...outlined,\n fontSize: 12,\n color: v.gold,\n }}\n >\n <Icon glyph=\"copy\" size={14} /> {t(\"invite\")}: {invite}\n </button>\n </div>\n\n {state.started ? (\n <Button tone=\"gold\" size=\"lg\" attract onClick={onBack}>\n {t(\"backToGame\")}\n </Button>\n ) : null}\n {noLink ? (\n <div style={{ ...outlined, fontSize: 13, color: v.red }}>\n {t(\"noLink\")}\n </div>\n ) : null}\n {state.error ? (\n <div style={{ ...outlined, fontSize: 13, color: v.red }}>\n {t(state.error === \"noGame\" ? \"noGame\" : \"startFailed\")}\n </div>\n ) : null}\n\n {state.voice ? <VoiceBar voice={state.voice} /> : null}\n\n <SectionTitle>{t(\"members\")}</SectionTitle>\n {rows.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n opacity: m.online ? 1 : 0.5,\n }}\n >\n <Icon glyph={m.host ? \"crown\" : \"user\"} size={24} />\n <span style={{ ...outlined, fontSize: 14, flex: 1, minWidth: 0 }}>\n {m.me ? `${m.name} (${t(\"you\")})` : m.name}\n {m.recording ? (\n <span style={{ color: v.red, fontSize: 11 }}>\n {\" \"}\n ● {t(\"recording\")}\n </span>\n ) : null}\n {needsLink(session.topology, session.isHost, m) ? (\n <span\n style={{\n display: \"block\",\n fontSize: 11,\n color: session.isOpen(m.userID) ? v.green : v.textDim,\n }}\n >\n {session.isOpen(m.userID) ? t(\"linkOk\") : t(\"linking\")}\n </span>\n ) : null}\n </span>\n {state.voice ? (\n <MemberVoice voice={state.voice} userId={m.userID} me={m.me} />\n ) : null}\n {m.ready ? <Icon glyph=\"check\" size={20} /> : null}\n {session.isHost && !m.me && !state.started ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(() => deps.rooms.kick(m.userID))}\n >\n {t(\"kick\")}\n </Button>\n ) : null}\n </Card>\n ))}\n\n {state.started ? null : session.isHost ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n disabled={!game}\n onClick={() => void act(() => deps.rooms.start())}\n data-tutorial-anchor=\"multiplayer:play\"\n >\n {t(\"play\")} · {gameLabel(game, deps.modes, session.mode)}\n </Button>\n ) : (\n <>\n <Button\n tone={me?.ready ? \"grey\" : \"green\"}\n busy={busy}\n onClick={() => void act(() => deps.rooms.setReady(!me?.ready))}\n >\n {me?.ready ? t(\"notReady\") : t(\"ready\")}\n </Button>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"waitHost\")}\n </div>\n </>\n )}\n\n {session.chatMode === \"ServerRelay\" ? (\n <div style={{ ...panel, padding: 10, display: \"grid\", gap: 8 }}>\n <div\n style={{\n display: \"grid\",\n gap: 4,\n maxHeight: 180,\n overflowY: \"auto\",\n }}\n >\n {state.chat.length === 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"noChat\")}\n </div>\n ) : (\n state.chat.map((c, i) => (\n <div key={i} style={{ fontSize: 13, color: v.text }}>\n <b>{nameOf(c.from)}:</b> {c.text}\n </div>\n ))\n )}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <TextInput\n value={text}\n maxLength={200}\n placeholder={t(\"message\")}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => {\n if (e.key !== \"Enter\") return;\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n style={{ padding: \"10px 12px\", flex: 1 }}\n />\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => {\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n >\n {t(\"send\")}\n </Button>\n </div>\n </div>\n ) : null}\n\n <Button tone=\"grey\" size=\"sm\" onClick={() => void deps.rooms.leave()}>\n {t(\"leave\")}\n </Button>\n </div>\n );\n}\n"
56
60
  },
57
61
  {
58
62
  "path": "components/RoomsList.tsx",
59
63
  "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\nimport { type RoomListItem } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n Icon,\n outlined,\n Popup,\n SectionTitle,\n Spinner,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { visibleRooms } from \"../model\";\nimport { t } from \"../i18n\";\nimport { CodeForm } from \"./CodeForm\";\nimport { CreateForm } from \"./CreateForm\";\nimport { gameLabel, useGames } from \"../helpers\";\nimport { type ScreenDeps } from \"../types\";\n\nexport function RoomsList({\n deps,\n mode,\n create,\n}: {\n deps: ScreenDeps;\n mode: string | null;\n create: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const stagger = useStagger();\n const games = useGames(deps.games);\n const [rooms, setRooms] = useState<RoomListItem[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [popup, setPopup] = useState<\"create\" | \"code\" | null>(\n create ? \"create\" : null,\n );\n const modesList = useMemo(() => games.map((g) => g.mode), [games]);\n\n const load = useCallback(async () => {\n const res = await client.multiplayer.listRooms(0, 50);\n setRooms(res.ok ? (res.data.Rooms ?? []) : []);\n }, [client]);\n useEffect(() => {\n void load();\n const id = setInterval(() => void load(), 5_000);\n return () => clearInterval(id);\n }, [load]);\n\n const join = async (roomID: string, code?: string) => {\n setBusy(roomID);\n const error = await deps.rooms.join(roomID, code);\n setBusy(null);\n if (error)\n toast(\n error === \"inProgress\" ? t(\"inProgress\") : errorText(error),\n \"error\",\n );\n };\n\n const shown = visibleRooms(rooms ?? [], modesList, mode);\n const title = mode\n ? `${t(\"roomsOf\")} ${gameLabel(deps.games.getGame(mode), deps.modes, mode)}`\n : t(\"open\");\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n attract\n onClick={() => setPopup(\"create\")}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"multiplayer:create\"\n >\n <Icon glyph=\"plus\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setPopup(\"code\")}>\n {t(\"byCode\")}\n </Button>\n </div>\n <SectionTitle>{title}</SectionTitle>\n {!rooms ? (\n <Center>\n <Spinner />\n </Center>\n ) : shown.length === 0 ? (\n <EmptyState glyph=\"globe\" text={t(\"noRooms\")} />\n ) : (\n shown.map((r, i) => (\n <Card\n key={r.RoomID}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"globe\" size={34} />\n <div style={{ minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {r.Name || t(\"room\")}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {gameLabel(\n deps.games.getGame(r.Mode ?? \"\"),\n deps.modes,\n r.Mode,\n )}{\" \"}\n · {t(\"players\")}: {r.MemberCount ?? 0} / {r.MaxPlayers ?? 8}\n </div>\n </div>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === r.RoomID}\n disabled={(r.MemberCount ?? 0) >= (r.MaxPlayers ?? 8)}\n onClick={() => void join(r.RoomID ?? \"\")}\n >\n {t(\"join\")}\n </Button>\n </Card>\n ))\n )}\n {popup === \"create\" ? (\n <Popup title={t(\"create\")} onClose={() => setPopup(null)} width={420}>\n <CreateForm deps={deps} games={games} preset={mode} />\n </Popup>\n ) : popup === \"code\" ? (\n <Popup title={t(\"byCode\")} onClose={() => setPopup(null)} width={400}>\n <CodeForm onJoin={(id, code) => void join(id, code)} />\n </Popup>\n ) : null}\n </div>\n );\n}\n"
60
64
  },
65
+ {
66
+ "path": "components/Voice.tsx",
67
+ "content": "import {\n useEffect,\n useSyncExternalStore,\n type MouseEvent as ReactMouseEvent,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from \"react\";\nimport type { MicMode, VoiceController } from \"@idosgames/core\";\nimport { type ModeRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n buttonStyle,\n configSection,\n outlined,\n panel,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\nimport { type Rooms } from \"../rooms\";\n\n// Voice chat controls of the room: the mic modes and push-to-talk in the room screen, each member's\n// \"speaking\" mark with mute and volume, and the small mic button over the running game (the room\n// screen is closed then). The controller itself lives in the rooms state (`rooms.get().voice`).\n\n/** The key held for push-to-talk while the game is on screen. */\nexport const PUSH_TO_TALK_KEY = \"KeyV\";\n\n/** Voices of other players go into the platform's videos — the publisher decides (default on). */\nexport function useRecordVoices(): boolean {\n const client = useIDosGamesClient();\n return configSection(client, \"Multiplayer\")?.RecordVoices !== false;\n}\n\n/** Controllers that already showed \"conversations may be recorded\" — once per room is enough. */\nconst noticed = new WeakSet<VoiceController>();\n\n/** Switch the mic mode, telling the player why it failed and, once, that talks may be recorded. */\nexport function useSetMicMode(voice: VoiceController | null) {\n const toast = useToast();\n const recordVoices = useRecordVoices();\n return async (mode: MicMode) => {\n if (!voice) return;\n const res = await voice.setMode(mode);\n if (!res.ok) {\n toast(\n res.reason === \"unauthorized\" ? t(\"micDenied\") : t(\"micFailed\"),\n \"error\",\n );\n return;\n }\n if (mode !== \"off\" && recordVoices && !noticed.has(voice)) {\n noticed.add(voice);\n toast(t(\"recordNotice\"));\n }\n };\n}\n\n/** Hold-to-talk on a button: pointer down — talk, up or leave — stop. */\nfunction holdProps(voice: VoiceController) {\n return {\n onPointerDown: (e: ReactPointerEvent) => {\n (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);\n voice.setPushed(true);\n },\n onPointerUp: () => voice.setPushed(false),\n onPointerCancel: () => voice.setPushed(false),\n onContextMenu: (e: ReactMouseEvent) => e.preventDefault(),\n };\n}\n\n/** The room screen's voice block: modes, push-to-talk, the recording notice. */\nexport function VoiceBar({ voice }: { voice: VoiceController }): ReactNode {\n const setMode = useSetMicMode(voice);\n const recordVoices = useRecordVoices();\n const modes: MicMode[] = [\"off\", \"push\", \"open\"];\n return (\n <div style={{ ...panel, padding: 10, display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14 }}>🎙 {t(\"voice\")}</div>\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {modes.map((m) => (\n <Button\n key={m}\n size=\"sm\"\n tone={voice.mode === m ? \"gold\" : \"grey\"}\n onClick={() => void setMode(m)}\n >\n {t(m === \"off\" ? \"micOff\" : m === \"push\" ? \"micPush\" : \"micOpen\")}\n </Button>\n ))}\n </div>\n {voice.mode === \"push\" ? (\n // A plain button: the kit's Button passes only clicks, and talking needs press-and-hold.\n <button\n type=\"button\"\n className=\"idos-press\"\n {...holdProps(voice)}\n style={{\n ...buttonStyle(voice.transmitting ? \"green\" : \"blue\"),\n touchAction: \"none\",\n userSelect: \"none\",\n }}\n >\n {voice.transmitting ? t(\"talking\") : t(\"holdToTalk\")}\n </button>\n ) : null}\n {recordVoices ? (\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n 🎙 {t(\"recordNotice\")}\n </div>\n ) : null}\n </div>\n );\n}\n\n/** A member's voice in the room list: speaking mark, and for others mute + volume. */\nexport function MemberVoice({\n voice,\n userId,\n me,\n}: {\n voice: VoiceController;\n userId: string;\n me: boolean;\n}): ReactNode {\n const speaking = voice.isSpeaking(userId);\n const muted = voice.isPeerMuted(userId);\n return (\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n <span\n aria-label={speaking ? t(\"speaking\") : undefined}\n style={{\n width: 10,\n height: 10,\n borderRadius: 5,\n background: speaking ? v.green : \"transparent\",\n boxShadow: speaking ? `0 0 8px ${v.green}` : \"none\",\n border: `1px solid ${speaking ? v.green : v.textDim}`,\n }}\n />\n {me || !voice.hasVoice(userId) ? null : (\n <>\n <input\n type=\"range\"\n min={0}\n max={200}\n step={10}\n value={Math.round(voice.peerVolume(userId) * 100)}\n aria-label={t(\"volume\")}\n onChange={(e) =>\n voice.setPeerVolume(userId, Number(e.target.value) / 100)\n }\n style={{ width: 64 }}\n />\n <Button\n size=\"sm\"\n tone={muted ? \"red\" : \"grey\"}\n onClick={() => voice.setPeerMuted(userId, !muted)}\n >\n {muted ? t(\"unmute\") : t(\"mute\")}\n </Button>\n </>\n )}\n </span>\n );\n}\n\n/**\n * The mic over the running game (a host panel in the `overlay` slot): tap to switch the open mic on\n * and off, hold in push-to-talk mode, the V key for push-to-talk, and who is speaking. Drawn only\n * while this player's room is in its game and the game is on screen.\n */\nexport function makeVoiceOverlay(rooms: Rooms, modes: ModeRegistry) {\n return function VoiceOverlay(): ReactNode {\n const state = useSyncExternalStore(rooms.subscribe, rooms.get, rooms.get);\n const current = useSyncExternalStore(\n modes.subscribe,\n () => modes.current(),\n () => modes.current(),\n );\n const voice = state.voice;\n const shown = !!voice && !!state.started && current === state.started;\n const setMode = useSetMicMode(voice);\n const recordVoices = useRecordVoices();\n\n useEffect(() => {\n if (!shown || !voice) return;\n const typing = (e: KeyboardEvent) => {\n const el = e.target as HTMLElement | null;\n return (\n !!el &&\n (el.tagName === \"INPUT\" ||\n el.tagName === \"TEXTAREA\" ||\n el.isContentEditable)\n );\n };\n const down = (e: KeyboardEvent) => {\n if (e.code !== PUSH_TO_TALK_KEY || e.repeat || typing(e)) return;\n if (voice.mode === \"push\") voice.setPushed(true);\n };\n const up = (e: KeyboardEvent) => {\n if (e.code === PUSH_TO_TALK_KEY) voice.setPushed(false);\n };\n const blur = () => voice.setPushed(false);\n // Capture phase: a game that stops propagation of its keys must not swallow push-to-talk.\n window.addEventListener(\"keydown\", down, true);\n window.addEventListener(\"keyup\", up, true);\n window.addEventListener(\"blur\", blur);\n return () => {\n window.removeEventListener(\"keydown\", down, true);\n window.removeEventListener(\"keyup\", up, true);\n window.removeEventListener(\"blur\", blur);\n };\n }, [shown, voice]);\n\n if (!shown || !voice) return null;\n const session = state.session;\n const nameOf = (id: string) =>\n session?.members.get(id)?.Username || t(\"player\");\n const talking = voice\n .speaking()\n .filter((id) => id !== session?.selfId)\n .map((id) => ({ id, name: nameOf(id) }));\n // Who is making a long recording right now (member state `rec`).\n const recorders = [...(session?.members.values() ?? [])]\n .filter(\n (m) => m.UserID && m.UserID !== session?.selfId && m.State?.rec === \"1\",\n )\n .map((m) => ({ id: m.UserID!, name: nameOf(m.UserID!) }));\n const push = voice.mode === \"push\";\n return (\n <div\n style={{\n position: \"absolute\",\n right: \"calc(var(--idos-safe-right, 0px) + 10px)\",\n top: \"38%\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"flex-end\",\n gap: 6,\n pointerEvents: \"none\",\n }}\n >\n {recorders.map(({ id, name }) => (\n <div\n key={`rec-${id}`}\n style={{\n ...outlined,\n fontSize: 11,\n padding: \"2px 8px\",\n borderRadius: 10,\n background: \"rgba(0,0,0,0.45)\",\n color: v.red,\n }}\n >\n ● {name} · {t(\"recording\")}\n </div>\n ))}\n {talking.map(({ id, name }) => (\n <div\n key={id}\n style={{\n ...outlined,\n fontSize: 12,\n padding: \"2px 8px\",\n borderRadius: 10,\n background: \"rgba(0,0,0,0.45)\",\n color: v.green,\n }}\n >\n 🔊 {name}\n </div>\n ))}\n <button\n type=\"button\"\n className=\"idos-press\"\n aria-label={t(\"voice\")}\n {...(push ? holdProps(voice) : {})}\n onClick={\n push\n ? undefined\n : () => void setMode(voice.mode === \"open\" ? \"off\" : \"open\")\n }\n style={{\n pointerEvents: \"auto\",\n width: 48,\n height: 48,\n borderRadius: 24,\n border: `2px solid ${voice.transmitting ? v.green : \"rgba(255,255,255,0.4)\"}`,\n background: voice.transmitting\n ? \"rgba(40,160,80,0.55)\"\n : \"rgba(0,0,0,0.45)\",\n fontSize: 22,\n cursor: \"pointer\",\n touchAction: \"none\",\n opacity: voice.mode === \"off\" ? 0.6 : 1,\n }}\n >\n {voice.mode === \"off\" ? \"🔇\" : \"🎙\"}\n </button>\n {recordVoices ? (\n <div\n style={{\n ...outlined,\n fontSize: 10,\n color: v.textDim,\n maxWidth: 120,\n }}\n >\n {t(\"recordShort\")}\n </div>\n ) : null}\n </div>\n );\n };\n}\n"
68
+ },
61
69
  {
62
70
  "path": "helpers.ts",
63
71
  "content": "import { useSyncExternalStore } from \"react\";\nimport {\n type ModeRegistry,\n type MultiplayerGame,\n type MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\n\nexport const useGames = (games: MultiplayerRegistry) =>\n useSyncExternalStore(games.subscribe, games.listGames, games.listGames);\n\n/** A game's label: its own, else its route's. */\nexport function gameLabel(\n game: MultiplayerGame | undefined,\n modes: ModeRegistry,\n mode: string | null | undefined,\n): string {\n return (\n game?.label ?? modes.list().find((m) => m.id === mode)?.label ?? mode ?? \"\"\n );\n}\n\n// ── The list of rooms ────────────────────────────────────────────────────────────────────────\n"
64
72
  },
65
73
  {
66
74
  "path": "i18n.ts",
67
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Multiplayer\",\n create: \"Create a room\",\n byCode: \"By invite\",\n open: \"Open rooms\",\n roomsOf: \"Rooms:\",\n noRooms: \"No open rooms — create one and invite friends.\",\n noGames: \"No game here plays online yet.\",\n room: \"Room\",\n roomName: \"Room name\",\n invitePlaceholder: \"Invite: room ID or ID:code\",\n invite: \"Invite\",\n copied: \"Copied!\",\n players: \"Players\",\n join: \"Join\",\n public: \"Anyone can join\",\n private: \"Private — by invite\",\n members: \"In the room\",\n you: \"you\",\n player: \"Player\",\n kick: \"Remove\",\n ready: \"Ready\",\n notReady: \"Not ready\",\n waitHost: \"The host starts the game.\",\n play: \"Play\",\n backToGame: \"Back to the game\",\n inProgress: \"The game in this room has already started.\",\n noGame: \"This app does not have the room's game.\",\n startFailed: \"The game could not start the room.\",\n connecting: \"Connecting to the game…\",\n linkOk: \"connected\",\n linking: \"connecting…\",\n noLink:\n \"Can't connect to the host. Check the network or ask the host to recreate the room.\",\n message: \"Message\",\n send: \"Send\",\n noChat: \"Say hi to the room!\",\n leave: \"Leave the room\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Мультиплеер\",\n create: \"Создать комнату\",\n byCode: \"По приглашению\",\n open: \"Открытые комнаты\",\n roomsOf: \"Комнаты:\",\n noRooms: \"Открытых комнат нет — создайте свою и позовите друзей.\",\n noGames: \"Здесь пока нет игр для игры по сети.\",\n room: \"Комната\",\n roomName: \"Название комнаты\",\n invitePlaceholder: \"Приглашение: ID комнаты или ID:код\",\n invite: \"Приглашение\",\n copied: \"Скопировано!\",\n players: \"Игроков\",\n join: \"Войти\",\n public: \"Может войти любой\",\n private: \"Закрытая — по приглашению\",\n members: \"В комнате\",\n you: \"вы\",\n player: \"Игрок\",\n kick: \"Убрать\",\n ready: \"Готов\",\n notReady: \"Не готов\",\n waitHost: \"Игру запускает хост.\",\n play: \"Играть\",\n backToGame: \"Вернуться в игру\",\n inProgress: \"Игра в этой комнате уже началась.\",\n noGame: \"В этом приложении нет игры этой комнаты.\",\n startFailed: \"Игра не смогла принять комнату.\",\n connecting: \"Подключение к игре…\",\n linkOk: \"связь есть\",\n linking: \"соединяемся…\",\n noLink:\n \"Не удаётся соединиться с хостом. Проверьте сеть или попросите хоста пересоздать комнату.\",\n message: \"Сообщение\",\n send: \"Отправить\",\n noChat: \"Поздоровайтесь с комнатой!\",\n leave: \"Выйти из комнаты\",\n});\n"
75
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Multiplayer\",\n create: \"Create a room\",\n byCode: \"By invite\",\n open: \"Open rooms\",\n roomsOf: \"Rooms:\",\n noRooms: \"No open rooms — create one and invite friends.\",\n noGames: \"No game here plays online yet.\",\n room: \"Room\",\n roomName: \"Room name\",\n invitePlaceholder: \"Invite: room ID or ID:code\",\n invite: \"Invite\",\n copied: \"Copied!\",\n players: \"Players\",\n join: \"Join\",\n public: \"Anyone can join\",\n private: \"Private — by invite\",\n members: \"In the room\",\n you: \"you\",\n player: \"Player\",\n kick: \"Remove\",\n ready: \"Ready\",\n notReady: \"Not ready\",\n waitHost: \"The host starts the game.\",\n play: \"Play\",\n backToGame: \"Back to the game\",\n inProgress: \"The game in this room has already started.\",\n noGame: \"This app does not have the room's game.\",\n startFailed: \"The game could not start the room.\",\n connecting: \"Connecting to the game…\",\n linkOk: \"connected\",\n linking: \"connecting…\",\n noLink:\n \"Can't connect to the host. Check the network or ask the host to recreate the room.\",\n message: \"Message\",\n send: \"Send\",\n noChat: \"Say hi to the room!\",\n leave: \"Leave the room\",\n voice: \"Voice chat\",\n voiceOn: \"Voice chat on\",\n voiceOff: \"Voice chat off\",\n micOff: \"Mic off\",\n micPush: \"Push to talk (V)\",\n micOpen: \"Open mic\",\n holdToTalk: \"Hold to talk\",\n talking: \"Talking…\",\n speaking: \"speaking\",\n mute: \"Mute\",\n unmute: \"Unmute\",\n volume: \"Volume\",\n micDenied: \"Microphone access is blocked — allow it in the browser to talk.\",\n micFailed: \"Could not turn the microphone on.\",\n recordNotice:\n \"Conversations may be recorded by other players in their videos.\",\n recordShort: \"Conversations may be recorded\",\n recording: \"recording\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Мультиплеер\",\n create: \"Создать комнату\",\n byCode: \"По приглашению\",\n open: \"Открытые комнаты\",\n roomsOf: \"Комнаты:\",\n noRooms: \"Открытых комнат нет — создайте свою и позовите друзей.\",\n noGames: \"Здесь пока нет игр для игры по сети.\",\n room: \"Комната\",\n roomName: \"Название комнаты\",\n invitePlaceholder: \"Приглашение: ID комнаты или ID:код\",\n invite: \"Приглашение\",\n copied: \"Скопировано!\",\n players: \"Игроков\",\n join: \"Войти\",\n public: \"Может войти любой\",\n private: \"Закрытая — по приглашению\",\n members: \"В комнате\",\n you: \"вы\",\n player: \"Игрок\",\n kick: \"Убрать\",\n ready: \"Готов\",\n notReady: \"Не готов\",\n waitHost: \"Игру запускает хост.\",\n play: \"Играть\",\n backToGame: \"Вернуться в игру\",\n inProgress: \"Игра в этой комнате уже началась.\",\n noGame: \"В этом приложении нет игры этой комнаты.\",\n startFailed: \"Игра не смогла принять комнату.\",\n connecting: \"Подключение к игре…\",\n linkOk: \"связь есть\",\n linking: \"соединяемся…\",\n noLink:\n \"Не удаётся соединиться с хостом. Проверьте сеть или попросите хоста пересоздать комнату.\",\n message: \"Сообщение\",\n send: \"Отправить\",\n noChat: \"Поздоровайтесь с комнатой!\",\n leave: \"Выйти из комнаты\",\n voice: \"Голосовой чат\",\n voiceOn: \"Голосовой чат включён\",\n voiceOff: \"Голосовой чат выключен\",\n micOff: \"Микрофон выкл.\",\n micPush: \"Нажми и говори (V)\",\n micOpen: \"Открытый микрофон\",\n holdToTalk: \"Держите, чтобы говорить\",\n talking: \"Говорите…\",\n speaking: \"говорит\",\n mute: \"Заглушить\",\n unmute: \"Вернуть звук\",\n volume: \"Громкость\",\n micDenied:\n \"Доступ к микрофону запрещён — разрешите его в браузере, чтобы говорить.\",\n micFailed: \"Не удалось включить микрофон.\",\n recordNotice: \"Разговоры могут записываться другими игроками в их роликах.\",\n recordShort: \"Разговоры могут записываться\",\n recording: \"запись\",\n});\n"
68
76
  },
69
77
  {
70
78
  "path": "index.ts",
@@ -72,11 +80,11 @@
72
80
  },
73
81
  {
74
82
  "path": "model.ts",
75
- "content": "// Pure helpers of the rooms system — covered by model.test.ts. No React, no network.\n\nimport type { MultiplayerGame } from \"@idosgames/module-sdk\";\nimport type { MultiplayerDefinitions } from \"@idosgames/core\";\n\nexport interface RoomMember {\n UserID?: string | null;\n Username?: string | null;\n Ready?: boolean | null;\n IsHost?: boolean | null;\n LastSeenMs?: number | null;\n State?: Record<string, string> | null;\n}\n\nexport interface MemberRow {\n userID: string;\n name: string;\n host: boolean;\n ready: boolean;\n online: boolean;\n me: boolean;\n}\n\n/** The room's members: host first, then by name; a member silent past the timeout is offline. */\nexport function memberRows(\n members: Iterable<RoomMember> | null | undefined,\n me: string | null | undefined,\n hostId: string | null | undefined,\n nowMs: number,\n timeoutMs: number,\n): MemberRow[] {\n return [...(members ?? [])]\n .filter((m): m is RoomMember & { UserID: string } => !!m?.UserID)\n .map((m) => ({\n userID: m.UserID,\n name: m.Username || m.UserID,\n host: hostId ? m.UserID === hostId : m.IsHost === true,\n ready: m.Ready === true,\n // The player looking at the list is online by definition (their own heartbeat in the local\n // snapshot is only as fresh as the last member refresh).\n online:\n m.UserID === me || !m.LastSeenMs || nowMs - m.LastSeenMs <= timeoutMs,\n me: m.UserID === me,\n }))\n .sort(\n (a, b) => Number(b.host) - Number(a.host) || a.name.localeCompare(b.name),\n );\n}\n\n/**\n * Whether this player needs a direct link to a member (and the room shows its state): in a star\n * (`Host`) the host links to everyone and a guest only to the host; in a mesh everyone to everyone.\n */\nexport function needsLink(\n topology: string | null | undefined,\n selfIsHost: boolean,\n member: Pick<MemberRow, \"me\" | \"host\">,\n): boolean {\n if (member.me) return false;\n return topology === \"Host\" && !selfIsHost ? member.host : true;\n}\n\n// ── The host's member state: the game's settings and \"the game started\" ─────────────────────\n//\n// The server keeps a small string map per member. The host's map carries the room's settings\n// (`set:<id>`) from the moment the room is created, and `start` once \"Play\" is pressed — so a player\n// who joins later reads both from the snapshot, and a guest who missed the DataChannel message still\n// learns about the start from the next member refresh.\n\nexport const START_KEY = \"start\";\nconst SETTING_PREFIX = \"set:\";\n\n/** The room's settings as the host's member state stores them. */\nexport function settingsToState(\n settings: Record<string, string | boolean>,\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [id, value] of Object.entries(settings))\n out[SETTING_PREFIX + id] =\n typeof value === \"boolean\" ? (value ? \"1\" : \"0\") : value;\n return out;\n}\n\n/** The room's settings read back from the host's member state, defaults filled in from the game. */\nexport function settingsFromState(\n game: Pick<MultiplayerGame, \"settings\"> | undefined,\n state: Record<string, string> | null | undefined,\n): Record<string, string | boolean> {\n const out: Record<string, string | boolean> = {};\n for (const field of game?.settings ?? []) {\n const raw = state?.[SETTING_PREFIX + field.id];\n out[field.id] =\n raw === undefined\n ? field.default\n : field.kind === \"toggle\"\n ? raw === \"1\"\n : field.options?.some((o) => o.value === raw)\n ? raw\n : field.default;\n }\n return out;\n}\n\n/** Default settings of a game (what \"Create a room\" starts with). */\nexport function defaultSettings(\n game: Pick<MultiplayerGame, \"settings\"> | undefined,\n): Record<string, string | boolean> {\n return settingsFromState(game, null);\n}\n\n/** The game mode the host started for the room (null — still in the lobby). */\nexport function startedMode(\n host: RoomMember | null | undefined,\n): string | null {\n return host?.State?.[START_KEY] || null;\n}\n\n// ── The lobby's own DataChannel message ──────────────────────────────────────────────────────\n//\n// \"Play\" reaches guests at once over the links the lobby already opened (the member-state poll of a\n// guest is slow on purpose). The game owns the channels only after it started, and the host sends\n// this before any game message, on the ordered channel — so a game never sees it.\n\nconst START_TAG = \"idos.lobby.start\";\n\nexport function encodeStart(mode: string): Uint8Array {\n return new TextEncoder().encode(JSON.stringify({ t: START_TAG, mode }));\n}\n\n/** The mode a lobby start message carries, or null for anything else. */\nexport function decodeStart(data: Uint8Array): string | null {\n if (data.length > 256 || data[0] !== 0x7b) return null;\n try {\n const msg = JSON.parse(new TextDecoder().decode(data)) as {\n t?: unknown;\n mode?: unknown;\n };\n return msg.t === START_TAG && typeof msg.mode === \"string\"\n ? msg.mode\n : null;\n } catch {\n return null;\n }\n}\n\n// ── Rooms list ───────────────────────────────────────────────────────────────────────────────\n\nexport interface ListedRoom {\n RoomID?: string | null;\n Name?: string | null;\n Mode?: string | null;\n MemberCount?: number | null;\n MaxPlayers?: number | null;\n}\n\n/** Rooms the player can see: only those of a game this app has; of one game when asked. */\nexport function visibleRooms(\n rooms: ListedRoom[],\n games: string[],\n onlyMode: string | null | undefined,\n): ListedRoom[] {\n return rooms.filter(\n (r) =>\n !!r.RoomID &&\n !!r.Mode &&\n games.includes(r.Mode) &&\n (!onlyMode || r.Mode === onlyMode),\n );\n}\n\n/** A join code the way players type it: letters and digits, upper case. */\nexport function cleanCode(value: string): string {\n return value\n .toUpperCase()\n .replace(/[^A-Z0-9]/g, \"\")\n .slice(0, 12);\n}\n\n/** A fresh private-room code: six letters and digits without look-alikes. */\nexport function newCode(random: () => number = Math.random): string {\n const abc = \"ABCDEFGHJKLMNPQRSTUVWXYZ23456789\";\n let out = \"\";\n for (let i = 0; i < 6; i++) out += abc[Math.floor(random() * abc.length)];\n return out;\n}\n\n/** Whether multiplayer is on for the title. */\nexport function hasTitleData(\n section: MultiplayerDefinitions | undefined,\n): boolean {\n return !!section && section.SystemState?.Enabled !== false;\n}\n"
83
+ "content": "// Pure helpers of the rooms system — covered by model.test.ts. No React, no network.\n\nimport type { MultiplayerGame } from \"@idosgames/module-sdk\";\nimport type { MultiplayerDefinitions } from \"@idosgames/core\";\n\nexport interface RoomMember {\n UserID?: string | null;\n Username?: string | null;\n Ready?: boolean | null;\n IsHost?: boolean | null;\n LastSeenMs?: number | null;\n State?: Record<string, string> | null;\n}\n\nexport interface MemberRow {\n userID: string;\n name: string;\n host: boolean;\n ready: boolean;\n online: boolean;\n me: boolean;\n /** Making a long recording on the platform right now (member state `rec`). */\n recording: boolean;\n}\n\n/** The room's members: host first, then by name; a member silent past the timeout is offline. */\nexport function memberRows(\n members: Iterable<RoomMember> | null | undefined,\n me: string | null | undefined,\n hostId: string | null | undefined,\n nowMs: number,\n timeoutMs: number,\n): MemberRow[] {\n return [...(members ?? [])]\n .filter((m): m is RoomMember & { UserID: string } => !!m?.UserID)\n .map((m) => ({\n userID: m.UserID,\n name: m.Username || m.UserID,\n host: hostId ? m.UserID === hostId : m.IsHost === true,\n ready: m.Ready === true,\n // The player looking at the list is online by definition (their own heartbeat in the local\n // snapshot is only as fresh as the last member refresh).\n online:\n m.UserID === me || !m.LastSeenMs || nowMs - m.LastSeenMs <= timeoutMs,\n me: m.UserID === me,\n recording: m.State?.rec === \"1\",\n }))\n .sort(\n (a, b) => Number(b.host) - Number(a.host) || a.name.localeCompare(b.name),\n );\n}\n\n/**\n * Whether this player needs a direct link to a member (and the room shows its state): in a star\n * (`Host`) the host links to everyone and a guest only to the host; in a mesh everyone to everyone.\n */\nexport function needsLink(\n topology: string | null | undefined,\n selfIsHost: boolean,\n member: Pick<MemberRow, \"me\" | \"host\">,\n): boolean {\n if (member.me) return false;\n return topology === \"Host\" && !selfIsHost ? member.host : true;\n}\n\n// ── The host's member state: the game's settings and \"the game started\" ─────────────────────\n//\n// The server keeps a small string map per member. The host's map carries the room's settings\n// (`set:<id>`) from the moment the room is created, and `start` once \"Play\" is pressed — so a player\n// who joins later reads both from the snapshot, and a guest who missed the DataChannel message still\n// learns about the start from the next member refresh.\n\nexport const START_KEY = \"start\";\nconst SETTING_PREFIX = \"set:\";\n\n/** The room's settings as the host's member state stores them. */\nexport function settingsToState(\n settings: Record<string, string | boolean>,\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [id, value] of Object.entries(settings))\n out[SETTING_PREFIX + id] =\n typeof value === \"boolean\" ? (value ? \"1\" : \"0\") : value;\n return out;\n}\n\n/** The room's settings read back from the host's member state, defaults filled in from the game. */\nexport function settingsFromState(\n game: Pick<MultiplayerGame, \"settings\"> | undefined,\n state: Record<string, string> | null | undefined,\n): Record<string, string | boolean> {\n const out: Record<string, string | boolean> = {};\n for (const field of game?.settings ?? []) {\n const raw = state?.[SETTING_PREFIX + field.id];\n out[field.id] =\n raw === undefined\n ? field.default\n : field.kind === \"toggle\"\n ? raw === \"1\"\n : field.options?.some((o) => o.value === raw)\n ? raw\n : field.default;\n }\n return out;\n}\n\n/** Default settings of a game (what \"Create a room\" starts with). */\nexport function defaultSettings(\n game: Pick<MultiplayerGame, \"settings\"> | undefined,\n): Record<string, string | boolean> {\n return settingsFromState(game, null);\n}\n\n/** The game mode the host started for the room (null — still in the lobby). */\nexport function startedMode(\n host: RoomMember | null | undefined,\n): string | null {\n return host?.State?.[START_KEY] || null;\n}\n\n// ── The lobby's own DataChannel message ──────────────────────────────────────────────────────\n//\n// \"Play\" reaches guests at once over the links the lobby already opened (the member-state poll of a\n// guest is slow on purpose). The game owns the channels only after it started, and the host sends\n// this before any game message, on the ordered channel — so a game never sees it.\n\nconst START_TAG = \"idos.lobby.start\";\n\nexport function encodeStart(mode: string): Uint8Array {\n return new TextEncoder().encode(JSON.stringify({ t: START_TAG, mode }));\n}\n\n/** The mode a lobby start message carries, or null for anything else. */\nexport function decodeStart(data: Uint8Array): string | null {\n if (data.length > 256 || data[0] !== 0x7b) return null;\n try {\n const msg = JSON.parse(new TextDecoder().decode(data)) as {\n t?: unknown;\n mode?: unknown;\n };\n return msg.t === START_TAG && typeof msg.mode === \"string\"\n ? msg.mode\n : null;\n } catch {\n return null;\n }\n}\n\n// ── Rooms list ───────────────────────────────────────────────────────────────────────────────\n\nexport interface ListedRoom {\n RoomID?: string | null;\n Name?: string | null;\n Mode?: string | null;\n MemberCount?: number | null;\n MaxPlayers?: number | null;\n}\n\n/** Rooms the player can see: only those of a game this app has; of one game when asked. */\nexport function visibleRooms(\n rooms: ListedRoom[],\n games: string[],\n onlyMode: string | null | undefined,\n): ListedRoom[] {\n return rooms.filter(\n (r) =>\n !!r.RoomID &&\n !!r.Mode &&\n games.includes(r.Mode) &&\n (!onlyMode || r.Mode === onlyMode),\n );\n}\n\n/** A join code the way players type it: letters and digits, upper case. */\nexport function cleanCode(value: string): string {\n return value\n .toUpperCase()\n .replace(/[^A-Z0-9]/g, \"\")\n .slice(0, 12);\n}\n\n/** A fresh private-room code: six letters and digits without look-alikes. */\nexport function newCode(random: () => number = Math.random): string {\n const abc = \"ABCDEFGHJKLMNPQRSTUVWXYZ23456789\";\n let out = \"\";\n for (let i = 0; i < 6; i++) out += abc[Math.floor(random() * abc.length)];\n return out;\n}\n\n/** Whether multiplayer is on for the title. */\nexport function hasTitleData(\n section: MultiplayerDefinitions | undefined,\n): boolean {\n return !!section && section.SystemState?.Enabled !== false;\n}\n"
76
84
  },
77
85
  {
78
86
  "path": "module.ts",
79
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMultiplayerScreen } from \"./MultiplayerScreen\";\nimport { hasTitleData } from \"./model\";\nimport { createRooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system of the app: the lobby tab with the rooms, and the owner of the player's room and\n// its connection. Games that play online register in `ctx.multiplayer` and take the room in `start`;\n// they never create or join rooms. The tab shows while multiplayer is on for the title AND the app has\n// a game that plays online. No badge and no startup request.\nexport const multiplayerModule: Module = defineModule({\n id: \"multiplayer\",\n meta: { name: \"Multiplayer\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const rooms = createRooms({\n client: ctx.client,\n games: ctx.multiplayer,\n navigate: (mode) => ctx.navigate(mode),\n loading: ctx.loading,\n connectingLabel: t(\"connecting\"),\n });\n ctx.features.register({\n id: \"multiplayer\",\n label: () => t(\"title\"),\n icon: \"globe\",\n group: \"social\",\n order: 75,\n available: false,\n Screen: makeMultiplayerScreen({\n rooms,\n games: ctx.multiplayer,\n modes: ctx.modes,\n }),\n });\n const on = hasTitleData(configSection(ctx.client, \"Multiplayer\"));\n // Games register in their own setup() — before or after this one: follow the registry.\n const refresh = () =>\n ctx.features.setAvailable(\n \"multiplayer\",\n on && ctx.multiplayer.listGames().length > 0,\n on\n ? \"no game of the app plays online (ctx.multiplayer.registerGame)\"\n : \"multiplayer is off on the title (Multiplayer.SystemState.Enabled)\",\n );\n refresh();\n ctx.multiplayer.subscribe(refresh);\n ctx.exposeToAgent({\n state: () => {\n const s = rooms.get();\n return {\n ui: \"dom\",\n feature: \"multiplayer\",\n games: ctx.multiplayer.listGames().map((g) => g.mode),\n room: s.session\n ? {\n id: s.session.roomId,\n mode: s.session.mode,\n host: s.session.isHost,\n members: s.session.members.size,\n started: s.started,\n }\n : null,\n };\n },\n describeActions: {},\n });\n },\n});\n"
87
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMultiplayerScreen } from \"./MultiplayerScreen\";\nimport { makeVoiceOverlay } from \"./components/Voice\";\nimport { hasTitleData } from \"./model\";\nimport { createRooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system of the app: the lobby tab with the rooms, and the owner of the player's room and\n// its connection. Games that play online register in `ctx.multiplayer` and take the room in `start`;\n// they never create or join rooms. The tab shows while multiplayer is on for the title AND the app has\n// a game that plays online. No badge and no startup request.\nexport const multiplayerModule: Module = defineModule({\n id: \"multiplayer\",\n meta: { name: \"Multiplayer\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const rooms = createRooms({\n client: ctx.client,\n games: ctx.multiplayer,\n navigate: (mode) => ctx.navigate(mode),\n loading: ctx.loading,\n connectingLabel: t(\"connecting\"),\n clips: ctx.clips,\n recordVoices: () =>\n configSection(ctx.client, \"Multiplayer\")?.RecordVoices !== false,\n });\n ctx.features.register({\n id: \"multiplayer\",\n label: () => t(\"title\"),\n icon: \"globe\",\n group: \"social\",\n order: 75,\n available: false,\n Screen: makeMultiplayerScreen({\n rooms,\n games: ctx.multiplayer,\n modes: ctx.modes,\n }),\n });\n // The mic over the running game: the room screen is closed while the game plays.\n ctx.registerPanel({\n id: \"voice\",\n slot: \"overlay\",\n component: makeVoiceOverlay(rooms, ctx.modes),\n activeOnly: false,\n });\n const on = hasTitleData(configSection(ctx.client, \"Multiplayer\"));\n // Games register in their own setup() — before or after this one: follow the registry.\n const refresh = () =>\n ctx.features.setAvailable(\n \"multiplayer\",\n on && ctx.multiplayer.listGames().length > 0,\n on\n ? \"no game of the app plays online (ctx.multiplayer.registerGame)\"\n : \"multiplayer is off on the title (Multiplayer.SystemState.Enabled)\",\n );\n refresh();\n ctx.multiplayer.subscribe(refresh);\n ctx.exposeToAgent({\n state: () => {\n const s = rooms.get();\n return {\n ui: \"dom\",\n feature: \"multiplayer\",\n games: ctx.multiplayer.listGames().map((g) => g.mode),\n room: s.session\n ? {\n id: s.session.roomId,\n mode: s.session.mode,\n host: s.session.isHost,\n members: s.session.members.size,\n started: s.started,\n voice: s.voice\n ? {\n mode: s.voice.mode,\n transmitting: s.voice.transmitting,\n speaking: s.voice.speaking(),\n }\n : null,\n }\n : null,\n };\n },\n describeActions: {},\n });\n },\n});\n"
80
88
  },
81
89
  {
82
90
  "path": "MultiplayerScreen.tsx",
@@ -84,15 +92,19 @@
84
92
  },
85
93
  {
86
94
  "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 /** 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"
95
+ "content": "import type { IDosGamesClient } from \"@idosgames/core\";\nimport { RealtimeSession, VoiceController } from \"@idosgames/core/realtime\";\nimport { createVoiceBurstDetector } from \"./voiceBurst\";\nimport type {\n ClipsRegistry,\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 /** The room's voice chat (null — the room has none). Lives as long as the session. */\n voice: VoiceController | 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 /** Test seam: the voice of a session. Default — a `VoiceController` for a voice room. */\n createVoice?: (session: RealtimeSession) => VoiceController | null;\n /**\n * The platform's video recording (`ctx.clips`): the room hands it its voices, and this player's\n * long recording shows as \"● recording\" to the others (member state `rec`).\n */\n clips?: ClipsRegistry;\n /** The publisher lets other players' voices into videos (`Multiplayer.RecordVoices`). */\n recordVoices?: () => boolean;\n}\n\n/** Member-state key: this player is making a long recording. */\nexport const REC_KEY = \"rec\";\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 /** Voice chat in the room; default on (the title may forbid it — then it is off regardless). */\n voice?: 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 voice: null,\n };\n const createVoice =\n deps.createVoice ??\n ((s: RealtimeSession) => (s.voice ? new VoiceController(s) : null));\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 deps.clips?.setVoiceSource(null);\n state.voice?.destroy();\n set({\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n voice: 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 voice: state.voice,\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 state.voice?.destroy();\n const voice = createVoice(session);\n set({ session, joinCode, started: null, chat: [], error: null, voice });\n // The recording takes this room's voices — or none (a room without voice must not leave the\n // previous room's controller hanging in the recorder).\n deps.clips?.setVoiceSource(\n voice\n ? {\n mic: () => voice.micTrack(),\n others: () => voice.peerTracks(),\n recordOthers: () => deps.recordVoices?.() ?? true,\n subscribe: (fn) => voice.on(\"change\", fn),\n }\n : null,\n );\n // This player's long recording, shown to the others in the room.\n const markRecording = () => {\n const me = session.members.get(session.selfId);\n const on = deps.clips?.isRecording() ?? false;\n if ((me?.State?.[REC_KEY] === \"1\") === on) return;\n const next = { ...(me?.State ?? {}) };\n if (on) next[REC_KEY] = \"1\";\n else delete next[REC_KEY];\n void deps.client.multiplayer\n .setMemberState(session.roomId, undefined, next)\n .then((res) => (res.ok ? session.refreshMembers() : undefined));\n };\n // Loud laughs and shouts of several players at once are a bright moment for the recording.\n const burst = createVoiceBurstDetector({\n onBurst: (weight) => deps.clips?.mark(\"voice_burst\", weight),\n });\n offs = [\n // Who speaks, mic mode, mutes — the room screen and the mic over the game redraw from here.\n ...(voice ? [voice.on(\"change\", () => set({}))] : []),\n ...(voice && deps.clips\n ? [voice.on(\"levels\", ({ levels }) => burst.feed(levels, Date.now()))]\n : []),\n ...(deps.clips ? [deps.clips.subscribe(markRecording)] : []),\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 // The whole map is replaced: keep this player's \"● recording\" in it.\n ...(deps.clips?.isRecording() ? { [REC_KEY]: \"1\" } : {}),\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 // Joined a room while already recording: the others learn at once.\n if (deps.clips?.isRecording()) markRecording();\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 // Off is the creator's choice; on is \"as the title allows\" (a title without voice refuses true).\n voice: o.voice === false ? false : undefined,\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
96
  },
89
97
  {
90
98
  "path": "types.ts",
91
99
  "content": "import {\n type ModeRegistry,\n type MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport { type Rooms } from \"./rooms\";\n\nexport interface ScreenDeps {\n rooms: Rooms;\n games: MultiplayerRegistry;\n modes: ModeRegistry;\n}\n"
92
100
  },
101
+ {
102
+ "path": "voiceBurst.ts",
103
+ "content": "// A \"bright moment\" heard in the voice chat: two or more players loud at once (laughing, shouting)\n// for over a second — almost always something funny or epic just happened. It works in ANY game with\n// voice, even one that marks nothing itself (owner, 15.10.2026). The mark goes to `ctx.clips.mark`;\n// the recorder turns the heaviest marks into \"Best moments\".\n\nexport interface VoiceBurstOptions {\n /** RMS level (0–1) that counts as loud. Default 0.08 — clearly above talking quietly. */\n loud?: number;\n /** How many voices at once. Default 2. */\n voices?: number;\n /** How long it has to last, ms. Default 1000. */\n minMs?: number;\n /** No second mark for this long after one, ms. Default 10 000. */\n cooldownMs?: number;\n onBurst: (weight: number) => void;\n}\n\n/** Feed it the voice levels every tick (`VoiceController` \"levels\"). */\nexport function createVoiceBurstDetector(o: VoiceBurstOptions) {\n const loud = o.loud ?? 0.08;\n const voices = o.voices ?? 2;\n const minMs = o.minMs ?? 1000;\n const cooldownMs = o.cooldownMs ?? 10_000;\n let since: number | null = null;\n let peak = 0;\n let last = -Infinity;\n return {\n feed(levels: ReadonlyMap<string, number>, now: number): void {\n let n = 0;\n for (const v of levels.values()) if (v >= loud) n++;\n if (n < voices) {\n since = null;\n peak = 0;\n return;\n }\n since ??= now;\n peak = Math.max(peak, n);\n if (now - since >= minMs && now - last >= cooldownMs) {\n last = now;\n since = null;\n // 2 voices → 4, every more voice +1, at most 8: louder rooms are rarer and better.\n o.onBurst(Math.min(8, 2 + peak));\n peak = 0;\n }\n },\n };\n}\n"
104
+ },
93
105
  {
94
106
  "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.4\"\n}\n"
107
+ "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, chat and voice 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), voice chat (mic off / push-to-talk on V / open mic, every member's mute and volume, who is speaking, a mic button over the running game, the \\\"Conversations may be recorded\\\" notice; the game reads room.voice for talking markers or proximity volume), 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 \"voice chat in rooms\",\n \"push-to-talk\",\n \"who is speaking\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\",\n \"voice\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.2.0\"\n}\n"
96
108
  }
97
109
  ]
98
110
  }
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.2"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.21.6",
36
- "@idosgames/module-sdk": "0.11.0",
37
- "@idosgames/react": "0.5.1",
35
+ "@idosgames/core": "0.22.1",
36
+ "@idosgames/module-sdk": "0.12.0",
37
+ "@idosgames/react": "0.5.2",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "fc37c3801909ed96833f1c07e64c659ca174de8667720d0f19f43f3613e754ac",