@idosgames/mcp 0.1.22 → 0.1.23

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 (40) hide show
  1. package/package.json +1 -1
  2. package/registry/host.json +2 -2
  3. package/registry/index.json +122 -122
  4. package/registry/modules/ai.json +5 -5
  5. package/registry/modules/board-game.json +9 -9
  6. package/registry/modules/boosts.json +7 -7
  7. package/registry/modules/character.json +6 -6
  8. package/registry/modules/chat.json +3 -3
  9. package/registry/modules/collections.json +5 -5
  10. package/registry/modules/coop-events.json +5 -5
  11. package/registry/modules/craft.json +5 -5
  12. package/registry/modules/deals.json +5 -5
  13. package/registry/modules/events.json +5 -5
  14. package/registry/modules/friends.json +5 -5
  15. package/registry/modules/idle-rpg.json +8 -8
  16. package/registry/modules/inventory.json +5 -5
  17. package/registry/modules/invite.json +5 -5
  18. package/registry/modules/leaderboard.json +5 -5
  19. package/registry/modules/lootboxes.json +6 -6
  20. package/registry/modules/mailbox.json +5 -5
  21. package/registry/modules/marketplace.json +6 -6
  22. package/registry/modules/mini-game.json +3 -3
  23. package/registry/modules/multiplayer.json +7 -7
  24. package/registry/modules/premium.json +5 -5
  25. package/registry/modules/profile.json +3 -3
  26. package/registry/modules/pvp.json +7 -7
  27. package/registry/modules/quests.json +5 -5
  28. package/registry/modules/rewards.json +5 -5
  29. package/registry/modules/season.json +5 -5
  30. package/registry/modules/store.json +5 -5
  31. package/registry/modules/tutorials.json +3 -3
  32. package/registry/modules/voxelcraft.json +6 -6
  33. package/registry/modules/workshop.json +3 -3
  34. package/registry/skills/blockchain-system.json +1 -1
  35. package/registry/skills/community-marketing-system.json +1 -1
  36. package/registry/skills/idosgames-getting-started.json +1 -1
  37. package/registry/skills/idosgames-ui-kit.json +1 -1
  38. package/registry/skills/mailbox-system.json +1 -1
  39. package/registry/skills/match-system.json +2 -2
  40. package/registry/skills/multiplayer-realtime.json +2 -2
@@ -35,12 +35,12 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.20.0",
39
- "@idosgames/module-sdk": "0.8.1",
40
- "@idosgames/react": "0.4.2",
38
+ "@idosgames/core": "0.21.0",
39
+ "@idosgames/module-sdk": "0.8.2",
40
+ "@idosgames/react": "0.4.3",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "14799b9981c650f938ddccbe51d243f5f6aeaf53105d7bd2536dffe6eabf818b",
43
+ "contentHash": "8b9697da40bac11042ce4099f484198c6ecfe4abd29c8c78eefd61d638ce84e2",
44
44
  "files": [
45
45
  {
46
46
  "path": "i18n.ts",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  {
54
54
  "path": "model.ts",
55
- "content": "// Pure helpers of the rooms system — covered by model.test.ts. No React, no network.\n\nimport type { MultiplayerGame } from \"@idosgames/module-sdk\";\n\n/** The title's Multiplayer section, as far as the rooms system cares. */\nexport interface MultiplayerSection {\n SystemState?: { Enabled?: boolean | null } | null;\n MaxPlayersPerRoom?: number | null;\n MemberTimeoutMs?: number | null;\n}\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 online: !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// ── 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(section: MultiplayerSection | undefined): boolean {\n return !!section && section.SystemState?.Enabled !== false;\n}\n"
55
+ "content": "// Pure helpers of the rooms system — covered by model.test.ts. No React, no network.\n\nimport type { MultiplayerGame } from \"@idosgames/module-sdk\";\n\n/** The title's Multiplayer section, as far as the rooms system cares. */\nexport interface MultiplayerSection {\n SystemState?: { Enabled?: boolean | null } | null;\n MaxPlayersPerRoom?: number | null;\n MemberTimeoutMs?: number | null;\n}\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// ── 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(section: MultiplayerSection | undefined): boolean {\n return !!section && section.SystemState?.Enabled !== false;\n}\n"
56
56
  },
57
57
  {
58
58
  "path": "module.ts",
@@ -60,11 +60,11 @@
60
60
  },
61
61
  {
62
62
  "path": "MultiplayerScreen.tsx",
63
- "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { RoomListItem } from \"@idosgames/core\";\nimport type {\n FeatureScreenProps,\n ModeRegistry,\n MultiplayerGame,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n SectionTitle,\n Spinner,\n configSection,\n errorText,\n outlined,\n panel,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n cleanCode,\n defaultSettings,\n memberRows,\n newCode,\n settingsFromState,\n visibleRooms,\n type MultiplayerSection,\n} from \"./model\";\nimport type { Rooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system's screen (a lobby tab): the rooms of the games this app plays online, \"Create a\n// room\" for one of them, joining by code — and, once in a room, its members, \"Ready\", the room chat and\n// the host's \"Play\", which takes the whole room into the game. Opened with `{ mode }` it shows one\n// game's rooms (the lobby's \"With friends\"); with `{ mode, create: true }` straight on \"Create\" (a\n// game's \"Open for friends\").\n\nexport interface ScreenDeps {\n rooms: Rooms;\n games: MultiplayerRegistry;\n modes: ModeRegistry;\n}\n\nconst useGames = (games: MultiplayerRegistry) =>\n useSyncExternalStore(games.subscribe, games.listGames, games.listGames);\n\nexport function makeMultiplayerScreen(deps: ScreenDeps) {\n return function MultiplayerScreen({ args }: FeatureScreenProps): ReactNode {\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const mode = typeof args?.mode === \"string\" ? args.mode : null;\n return state.session ? (\n <Room deps={deps} />\n ) : (\n <Rooms deps={deps} mode={mode} create={args?.create === true} />\n );\n };\n}\n\n/** A game's label: its own, else its route's. */\nfunction 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\nfunction Rooms({\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\nconst input = {\n ...panel,\n padding: \"10px 12px\",\n color: v.text,\n fontSize: 15,\n width: \"100%\",\n boxSizing: \"border-box\" as const,\n};\n\nfunction 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(\n configSection<MultiplayerSection>(client, \"Multiplayer\")\n ?.MaxPlayersPerRoom ?? 8,\n ),\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 <input\n value={name}\n maxLength={40}\n placeholder={t(\"roomName\")}\n onChange={(e) => setName(e.target.value)}\n style={input}\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\nfunction CodeForm({\n onJoin,\n}: {\n onJoin: (roomID: string, code: string) => void;\n}): ReactNode {\n const close = usePopupClose();\n const [invite, setInvite] = useState(\"\");\n // An invite is \"<room id>:<code>\" (what the room screen shows for copying); a bare id works too.\n const [roomId, code = \"\"] = invite.trim().split(\":\");\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={invite}\n placeholder={t(\"invitePlaceholder\")}\n onChange={(e) => setInvite(e.target.value)}\n style={input}\n />\n <Button\n tone=\"green\"\n disabled={!roomId}\n onClick={() => {\n close();\n onJoin(roomId ?? \"\", cleanCode(code));\n }}\n >\n {t(\"join\")}\n </Button>\n </div>\n );\n}\n\n// ── In a room ────────────────────────────────────────────────────────────────────────────────\n\nfunction Room({ deps }: { deps: ScreenDeps }): 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<MultiplayerSection>(client, \"Multiplayer\")?.MemberTimeoutMs ??\n 15_000,\n );\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 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\n tone=\"gold\"\n size=\"lg\"\n attract\n onClick={() => deps.rooms.resume()}\n >\n {t(\"backToGame\")}\n </Button>\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 }}>\n {m.me ? `${m.name} (${t(\"you\")})` : m.name}\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 <input\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={{ ...input, 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"
63
+ "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { RoomListItem } from \"@idosgames/core\";\nimport type {\n FeatureScreenProps,\n ModeRegistry,\n MultiplayerGame,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n SectionTitle,\n Spinner,\n configSection,\n errorText,\n outlined,\n panel,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n cleanCode,\n defaultSettings,\n memberRows,\n newCode,\n settingsFromState,\n visibleRooms,\n type MultiplayerSection,\n} from \"./model\";\nimport type { Rooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system's screen (a lobby tab): the rooms of the games this app plays online, \"Create a\n// room\" for one of them, joining by code — and, once in a room, its members, \"Ready\", the room chat and\n// the host's \"Play\", which takes the whole room into the game. Opened with `{ mode }` it shows one\n// game's rooms (the lobby's \"With friends\"); with `{ mode, create: true }` straight on \"Create\" (a\n// game's \"Open for friends\").\n\nexport interface ScreenDeps {\n rooms: Rooms;\n games: MultiplayerRegistry;\n modes: ModeRegistry;\n}\n\nconst useGames = (games: MultiplayerRegistry) =>\n useSyncExternalStore(games.subscribe, games.listGames, games.listGames);\n\nexport function makeMultiplayerScreen(deps: ScreenDeps) {\n return function MultiplayerScreen({ args }: FeatureScreenProps): ReactNode {\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const mode = typeof args?.mode === \"string\" ? args.mode : null;\n return state.session ? (\n <Room deps={deps} />\n ) : (\n <Rooms deps={deps} mode={mode} create={args?.create === true} />\n );\n };\n}\n\n/** A game's label: its own, else its route's. */\nfunction 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\nfunction Rooms({\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\nconst input = {\n ...panel,\n padding: \"10px 12px\",\n color: v.text,\n fontSize: 15,\n width: \"100%\",\n boxSizing: \"border-box\" as const,\n};\n\nfunction 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(\n configSection<MultiplayerSection>(client, \"Multiplayer\")\n ?.MaxPlayersPerRoom ?? 8,\n ),\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 <input\n value={name}\n maxLength={40}\n placeholder={t(\"roomName\")}\n onChange={(e) => setName(e.target.value)}\n style={input}\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\nfunction CodeForm({\n onJoin,\n}: {\n onJoin: (roomID: string, code: string) => void;\n}): ReactNode {\n const close = usePopupClose();\n const [invite, setInvite] = useState(\"\");\n // An invite is \"<room id>:<code>\" (what the room screen shows for copying); a bare id works too.\n const [roomId, code = \"\"] = invite.trim().split(\":\");\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={invite}\n placeholder={t(\"invitePlaceholder\")}\n onChange={(e) => setInvite(e.target.value)}\n style={input}\n />\n <Button\n tone=\"green\"\n disabled={!roomId}\n onClick={() => {\n close();\n onJoin(roomId ?? \"\", cleanCode(code));\n }}\n >\n {t(\"join\")}\n </Button>\n </div>\n );\n}\n\n// ── In a room ────────────────────────────────────────────────────────────────────────────────\n\nfunction Room({ deps }: { deps: ScreenDeps }): 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<MultiplayerSection>(client, \"Multiplayer\")?.MemberTimeoutMs ??\n 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 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\n tone=\"gold\"\n size=\"lg\"\n attract\n onClick={() => deps.rooms.resume()}\n >\n {t(\"backToGame\")}\n </Button>\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 }}>\n {m.me ? `${m.name} (${t(\"you\")})` : m.name}\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 <input\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={{ ...input, 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"
64
64
  },
65
65
  {
66
66
  "path": "rooms.ts",
67
- "content": "import type { IDosGamesClient } from \"@idosgames/core\";\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\nimport type {\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\n/** Test seam: how sessions are opened. */\nexport interface SessionFactory {\n create: typeof RealtimeSession.create;\n join: typeof RealtimeSession.join;\n}\n\nexport interface CreateOptions {\n name: string;\n mode: string;\n maxPlayers: number;\n private: boolean;\n joinCode: string;\n settings: Record<string, string | boolean>;\n}\n\nexport function createRooms(\n deps: RoomsDeps,\n factory: SessionFactory = {\n create: (...a) => RealtimeSession.create(...a),\n join: (...a) => RealtimeSession.join(...a),\n },\n) {\n let state: RoomsState = {\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n };\n const listeners = new Set<() => void>();\n let offs: Array<() => void> = [];\n const set = (patch: Partial<RoomsState>) => {\n state = { ...state, ...patch };\n for (const l of listeners) l();\n };\n\n const reset = () => {\n for (const off of offs.splice(0)) off();\n set({\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n });\n };\n\n /** Hand the room to the game — once per room. */\n const enter = (mode: string, lateJoin: boolean) => {\n const session = state.session;\n if (!session || state.started) return;\n const game = deps.games.getGame(mode);\n if (!game) {\n set({ error: \"noGame\" });\n return;\n }\n const host = session.members.get(session.hostId);\n const room: MultiplayerRoom = {\n session,\n mode,\n isHost: session.isHost,\n settings: settingsFromState(game, host?.State),\n lateJoin,\n leave: () => api.leave(),\n };\n set({ started: mode, error: null });\n deps.navigate(mode);\n // After the switch: the game's scene becomes active on the next render.\n setTimeout(() => {\n void Promise.resolve(game.start(room)).catch(() =>\n set({ error: \"startFailed\" }),\n );\n }, 0);\n };\n\n const attach = (session: RealtimeSession, joinCode: string | null) => {\n for (const off of offs.splice(0)) off();\n set({ session, joinCode, started: null, chat: [], error: null });\n offs = [\n session.on(\"message\", ({ from, data }) => {\n if (state.started || from !== session.hostId) return;\n const mode = decodeStart(data);\n if (mode) enter(mode, false);\n }),\n session.on(\"membersUpdated\", () => {\n const mode = startedMode(session.members.get(session.hostId));\n if (mode && !state.started) enter(mode, false);\n else set({});\n }),\n session.on(\"hostChanged\", () => set({})),\n 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 attach(res.data, o.private ? o.joinCode : null);\n // The settings live in the host's member state: whoever joins reads them from the snapshot.\n const saved = await deps.client.multiplayer.setMemberState(\n res.data.roomId,\n undefined,\n settingsToState(o.settings),\n );\n if (!saved.ok) return saved.error;\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 return res.ok ? null : res.error;\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"
67
+ "content": "import type { IDosGamesClient } from \"@idosgames/core\";\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\nimport type {\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\n/** Test seam: how sessions are opened. */\nexport interface SessionFactory {\n create: typeof RealtimeSession.create;\n join: typeof RealtimeSession.join;\n}\n\nexport interface CreateOptions {\n name: string;\n mode: string;\n maxPlayers: number;\n private: boolean;\n joinCode: string;\n settings: Record<string, string | boolean>;\n}\n\nexport function createRooms(\n deps: RoomsDeps,\n factory: SessionFactory = {\n create: (...a) => RealtimeSession.create(...a),\n join: (...a) => RealtimeSession.join(...a),\n },\n) {\n let state: RoomsState = {\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n };\n const listeners = new Set<() => void>();\n let offs: Array<() => void> = [];\n const set = (patch: Partial<RoomsState>) => {\n state = { ...state, ...patch };\n for (const l of listeners) l();\n };\n\n const reset = () => {\n for (const off of offs.splice(0)) off();\n set({\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n });\n };\n\n /** Hand the room to the game — once per room. */\n const enter = (mode: string, lateJoin: boolean) => {\n const session = state.session;\n if (!session || state.started) return;\n const game = deps.games.getGame(mode);\n if (!game) {\n set({ error: \"noGame\" });\n return;\n }\n const host = session.members.get(session.hostId);\n const room: MultiplayerRoom = {\n session,\n mode,\n isHost: session.isHost,\n settings: settingsFromState(game, host?.State),\n lateJoin,\n leave: () => api.leave(),\n };\n set({ started: mode, error: null });\n deps.navigate(mode);\n // After the switch: the game's scene becomes active on the next render.\n setTimeout(() => {\n void Promise.resolve(game.start(room)).catch(() =>\n set({ error: \"startFailed\" }),\n );\n }, 0);\n };\n\n const attach = (session: RealtimeSession, joinCode: string | null) => {\n for (const off of offs.splice(0)) off();\n set({ session, joinCode, started: null, chat: [], error: null });\n offs = [\n session.on(\"message\", ({ from, data }) => {\n if (state.started || from !== session.hostId) return;\n const mode = decodeStart(data);\n if (mode) enter(mode, false);\n }),\n session.on(\"membersUpdated\", () => {\n const mode = startedMode(session.members.get(session.hostId));\n if (mode && !state.started) enter(mode, false);\n else set({});\n }),\n session.on(\"hostChanged\", () => set({})),\n 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 return res.ok ? null : res.error;\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"
68
68
  },
69
69
  {
70
70
  "path": "module.meta.json",
@@ -32,12 +32,12 @@
32
32
  "version": "0.1.1"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.20.0",
36
- "@idosgames/module-sdk": "0.8.1",
37
- "@idosgames/react": "0.4.2",
35
+ "@idosgames/core": "0.21.0",
36
+ "@idosgames/module-sdk": "0.8.2",
37
+ "@idosgames/react": "0.4.3",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "5f2472fa890edc2500ec4f0703aa320a84862d0768d2ce2b576f0460adcc5eca",
40
+ "contentHash": "a6b14ae8d37b1bc7b7aefc6f38a0f53d381e283a55c50e0081ae97256108d7ff",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -57,7 +57,7 @@
57
57
  },
58
58
  {
59
59
  "path": "PremiumScreen.tsx",
60
- "content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumSection,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Fresh state on open: a pass may have run out since the last read.\n useEffect(() => {\n void client.premium.getUserState();\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </div>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n row.premiumID,\n crypto.randomUUID(),\n optionID,\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"gold\"),\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function makePremiumWatcher(features: FeatureRegistry) {\n return function PremiumBadge(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n }, [client, state?.Premium]);\n return null;\n };\n}\n"
60
+ "content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumSection,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Fresh state on open: a pass may have run out since the last read.\n useEffect(() => {\n void client.premium.getUserState();\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </div>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n row.premiumID,\n crypto.randomUUID(),\n optionID,\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"gold\"),\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function makePremiumWatcher(features: FeatureRegistry) {\n return function PremiumBadge(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // Subscribed directly, not keyed on the state object: the cache may patch it in place, and an\n // effect that does not re-run leaves the badge lit after the trial is taken.\n const update = () =>\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n client.data.user.state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n update();\n const off = client.on(\"user:anyUpdated\", update);\n // A pass running out brings its trial back into view — no event for that, so look now and then.\n const id = setInterval(update, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n return null;\n };\n}\n"
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.20.0",
39
- "@idosgames/module-sdk": "0.8.1",
40
- "@idosgames/react": "0.4.2",
38
+ "@idosgames/core": "0.21.0",
39
+ "@idosgames/module-sdk": "0.8.2",
40
+ "@idosgames/react": "0.4.3",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "bcb4c2b1d773155f758fb2cde1ae7cf4a70f98b7bc50b37bfba2e9b83fc0d101",
@@ -33,16 +33,16 @@
33
33
  "version": "0.1.0"
34
34
  },
35
35
  "dependencies": {
36
- "@idosgames/core": "0.20.0",
37
- "@idosgames/module-sdk": "0.8.1",
38
- "@idosgames/react": "0.4.2",
36
+ "@idosgames/core": "0.21.0",
37
+ "@idosgames/module-sdk": "0.8.2",
38
+ "@idosgames/react": "0.4.3",
39
39
  "react": "19.2.7"
40
40
  },
41
- "contentHash": "bafe16c7215f36a5ef659c24f879c07b02fdb5d7e7cedc1cdc4ef996e687879c",
41
+ "contentHash": "422f135f64903ca4f30425c1f2da4e7093a0f81e5877d65184fa7bc5a0eab846",
42
42
  "files": [
43
43
  {
44
44
  "path": "i18n.ts",
45
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Arena\",\n open: \"Open duels\",\n mine: \"My duels\",\n create: \"New duel\",\n strategy: \"Strategy\",\n noOpen: \"No open duels — create one!\",\n noMine: \"You have no duels yet.\",\n entry: \"Entry\",\n winnerTakes: \"Winner takes\",\n hero: \"Hero\",\n rule: \"Rule\",\n fight: \"Fight!\",\n cancel: \"Cancel duel\",\n cancelled: \"Cancelled\",\n waiting: \"Waiting for an opponent\",\n won: \"Victory!\",\n lost: \"Defeat\",\n draw: \"Draw — entries returned\",\n youWon: \"You won\",\n youLost: \"You lost\",\n rounds: \"Rounds\",\n attack: \"Attack\",\n defense: \"Defence\",\n round: \"Round\",\n save: \"Save\",\n saved: \"Strategy saved\",\n addRound: \"+ round\",\n head: \"Head\",\n torso: \"Body\",\n legs: \"Legs\",\n hHit: \"hit\",\n hCritical: \"critical!\",\n hBlock: \"blocked\",\n hDodge: \"dodged\",\n you: \"You\",\n foe: \"Foe\",\n hp: \"HP\",\n player: \"Player\",\n noHero: \"You need a hero to fight.\",\n toHeroes: \"Heroes\",\n yours: \"Your duel\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Арена\",\n open: \"Доступные\",\n mine: \"Мои дуэли\",\n create: \"Новая дуэль\",\n strategy: \"Стратегия\",\n noOpen: \"Открытых дуэлей нет — создайте свою!\",\n noMine: \"У вас пока нет дуэлей.\",\n entry: \"Взнос\",\n winnerTakes: \"Победитель получит\",\n hero: \"Герой\",\n rule: \"Правило\",\n fight: \"В бой!\",\n cancel: \"Отменить дуэль\",\n cancelled: \"Отменена\",\n waiting: \"Ждёт соперника\",\n won: \"Победа!\",\n lost: \"Поражение\",\n draw: \"Ничья — взносы вернулись\",\n youWon: \"Вы выиграли\",\n youLost: \"Вы проиграли\",\n rounds: \"Раунды\",\n attack: \"Удар\",\n defense: \"Защита\",\n round: \"Раунд\",\n save: \"Сохранить\",\n saved: \"Стратегия сохранена\",\n addRound: \"+ раунд\",\n head: \"Голова\",\n torso: \"Корпус\",\n legs: \"Ноги\",\n hHit: \"удар\",\n hCritical: \"крит!\",\n hBlock: \"блок\",\n hDodge: \"уворот\",\n you: \"Вы\",\n foe: \"Соперник\",\n hp: \"ОЗ\",\n player: \"Игрок\",\n noHero: \"Для боя нужен герой.\",\n toHeroes: \"Герои\",\n yours: \"Ваша дуэль\",\n});\n"
45
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Arena\",\n open: \"Open duels\",\n mine: \"My duels\",\n create: \"New duel\",\n strategy: \"Strategy\",\n noOpen: \"No open duels — create one!\",\n noMine: \"You have no duels yet.\",\n entry: \"Entry\",\n winnerTakes: \"Winner takes\",\n hero: \"Hero\",\n rule: \"Rule\",\n fight: \"Fight!\",\n cancel: \"Cancel duel\",\n cancelled: \"Cancelled\",\n expired: \"Expired — entry returned\",\n waiting: \"Waiting for an opponent\",\n won: \"Victory!\",\n lost: \"Defeat\",\n draw: \"Draw — entries returned\",\n youWon: \"You won\",\n youLost: \"You lost\",\n rounds: \"Rounds\",\n attack: \"Attack\",\n defense: \"Defence\",\n round: \"Round\",\n save: \"Save\",\n saved: \"Strategy saved\",\n addRound: \"+ round\",\n head: \"Head\",\n torso: \"Body\",\n legs: \"Legs\",\n hHit: \"hit\",\n hCritical: \"critical!\",\n hBlock: \"blocked\",\n hDodge: \"dodged\",\n you: \"You\",\n foe: \"Foe\",\n hp: \"HP\",\n player: \"Player\",\n noHero: \"You need a hero to fight.\",\n toHeroes: \"Heroes\",\n yours: \"Your duel\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Арена\",\n open: \"Доступные\",\n mine: \"Мои дуэли\",\n create: \"Новая дуэль\",\n strategy: \"Стратегия\",\n noOpen: \"Открытых дуэлей нет — создайте свою!\",\n noMine: \"У вас пока нет дуэлей.\",\n entry: \"Взнос\",\n winnerTakes: \"Победитель получит\",\n hero: \"Герой\",\n rule: \"Правило\",\n fight: \"В бой!\",\n cancel: \"Отменить дуэль\",\n cancelled: \"Отменена\",\n expired: \"Срок вышел — взнос возвращён\",\n waiting: \"Ждёт соперника\",\n won: \"Победа!\",\n lost: \"Поражение\",\n draw: \"Ничья — взносы вернулись\",\n youWon: \"Вы выиграли\",\n youLost: \"Вы проиграли\",\n rounds: \"Раунды\",\n attack: \"Удар\",\n defense: \"Защита\",\n round: \"Раунд\",\n save: \"Сохранить\",\n saved: \"Стратегия сохранена\",\n addRound: \"+ раунд\",\n head: \"Голова\",\n torso: \"Корпус\",\n legs: \"Ноги\",\n hHit: \"удар\",\n hCritical: \"крит!\",\n hBlock: \"блок\",\n hDodge: \"уворот\",\n you: \"Вы\",\n foe: \"Соперник\",\n hp: \"ОЗ\",\n player: \"Игрок\",\n noHero: \"Для боя нужен герой.\",\n toHeroes: \"Герои\",\n yours: \"Ваша дуэль\",\n});\n"
46
46
  },
47
47
  {
48
48
  "path": "index.ts",
@@ -54,11 +54,11 @@
54
54
  },
55
55
  {
56
56
  "path": "module.ts",
57
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makePvpScreen } from \"./PvpScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels as a feature of the game: open duels to join, my duels, and creating one. The fight is\n// resolved instantly by the server from the heroes' stats and the saved strategy.\nexport const pvpModule: Module = defineModule({\n id: \"pvp\",\n meta: { name: \"Arena\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"pvp\",\n label: t(\"title\"),\n icon: \"swords\",\n group: \"social\",\n order: 95,\n available: false,\n Screen: makePvpScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"pvp\",\n hasTitleData(\n configSection<Parameters<typeof hasTitleData>[0]>(ctx.client, \"Match\"),\n ),\n \"no duel rules on the title (Match.InstantBattle.Rules)\",\n );\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"pvp\" }),\n describeActions: {},\n });\n },\n});\n"
57
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makePvpScreen } from \"./PvpScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels as a feature of the game: open duels to join, my duels, and creating one. The fight is\n// resolved instantly by the server from the heroes' stats and the saved strategy.\nexport const pvpModule: Module = defineModule({\n id: \"pvp\",\n meta: { name: \"Arena\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"pvp\",\n label: t(\"title\"),\n icon: \"swords\",\n group: \"social\",\n order: 95,\n available: false,\n Screen: makePvpScreen(ctx.features),\n });\n const decide = () =>\n ctx.features.setAvailable(\n \"pvp\",\n hasTitleData(\n configSection<Parameters<typeof hasTitleData>[0]>(\n ctx.client,\n \"Match\",\n ),\n ),\n \"no duel rules on the title (Match.InstantBattle.Rules)\",\n );\n decide();\n // The engine does not put Match into the client's title config: read it once with its own call\n // (it lands in the same config section). Without this the tab never showed on any title.\n if (!configSection(ctx.client, \"Match\"))\n void ctx.client.match.getDefinitions().then(decide);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"pvp\" }),\n describeActions: {},\n });\n },\n});\n"
58
58
  },
59
59
  {
60
60
  "path": "PvpScreen.tsx",
61
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n InstantBattleResponse,\n MatchDefinitions,\n PvPMatch,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n ZONES,\n clampEntry,\n duelRules,\n matchEntry,\n outcomeFor,\n roundLines,\n strategyOrDefault,\n winnerTakes,\n type DuelRule,\n type Step,\n type Zone,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels (Unity Alikhan/PVP/Lobby): open duels to join and my duels as tabs. Creating a duel\n// (rule, entry, hero), the battle strategy (attack / defence zone per round) and the fight's result\n// round by round are popups. The server fights the battle instantly; the winner takes both entries\n// minus the burn, a draw returns them.\n\ntype Tab = \"open\" | \"mine\";\ntype Hero = { id: string; name: string; power: number };\n\nfunction useHeroes(): Hero[] | null {\n const client = useIDosGamesClient();\n const [heroes, setHeroes] = useState<Hero[] | null>(null);\n useEffect(() => {\n void client.character.getUserCharacters().then((r) => {\n const defs =\n (\n client.data.config.getSection(\"Character\") as\n | {\n Definitions?: Record<\n string,\n { Identity?: { DisplayName?: string | null } | null } | null\n >;\n }\n | undefined\n )?.Definitions ?? {};\n const owned = r.ok ? Object.values(r.data.Characters ?? {}) : [];\n setHeroes(\n owned\n .filter((c) => c?.CharacterID)\n .map((c) => ({\n id: c!.CharacterID!,\n name:\n defs[c!.CharacterID!]?.Identity?.DisplayName ?? c!.CharacterID!,\n power: Number(c!.Power ?? 0),\n }))\n .sort((a, b) => b.power - a.power),\n );\n });\n }, [client]);\n return heroes;\n}\n\nexport function makePvpScreen(features: FeatureRegistry) {\n return function PvpScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<MatchDefinitions | null | undefined>(\n undefined,\n );\n const [tab, setTab] = useState<Tab>(\"open\");\n const [creating, setCreating] = useState(false);\n const [editing, setEditing] = useState(false);\n const [joining, setJoining] = useState<PvPMatch | null>(null);\n const [result, setResult] = useState<InstantBattleResponse | null>(null);\n const [version, setVersion] = useState(0);\n const heroes = useHeroes();\n useEffect(() => {\n void client.match\n .getDefinitions()\n .then((r) => setDefs(r.ok ? r.data : null));\n }, [client]);\n const firstCurrency =\n Object.keys(\n (\n client.data.config.getSection(\"Currency\") as\n { VirtualCurrencies?: Record<string, unknown> } | undefined\n )?.VirtualCurrencies ?? {},\n )[0] ?? null;\n const rules = duelRules(defs as never, firstCurrency);\n const refresh = () => setVersion((n) => n + 1);\n\n if (defs === undefined || !heroes)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (heroes.length === 0)\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <EmptyState glyph=\"swords\" text={t(\"noHero\")} />\n {features.get(\"character\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"character\")}>\n {t(\"toHeroes\")}\n </Button>\n ) : null}\n </div>\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n <Button\n tone=\"gold\"\n attract\n onClick={() => setCreating(true)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"pvp:create\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setEditing(true)}>\n <Icon glyph=\"gear\" size={18} /> {t(\"strategy\")}\n </Button>\n </div>\n <Tabs\n tabs={[\n { id: \"open\", label: t(\"open\") },\n { id: \"mine\", label: t(\"mine\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div key={`${tab}:${version}`}>\n {tab === \"open\" ? (\n <OpenDuels rules={rules} onJoin={setJoining} />\n ) : (\n <MyDuels rules={rules} onChange={refresh} />\n )}\n </div>\n {creating ? (\n <CreatePopup\n rules={rules}\n heroes={heroes}\n onClose={() => setCreating(false)}\n onCreated={() => {\n setTab(\"mine\");\n refresh();\n }}\n />\n ) : null}\n {editing ? <StrategyPopup onClose={() => setEditing(false)} /> : null}\n {joining ? (\n <JoinPopup\n match={joining}\n rule={rules.find((r) => r.id === joining.RuleID) ?? rules[0]!}\n heroes={heroes}\n onClose={() => setJoining(null)}\n onFought={(res) => {\n setJoining(null);\n setResult(res);\n refresh();\n }}\n />\n ) : null}\n {result ? (\n <ResultPopup result={result} onClose={() => setResult(null)} />\n ) : null}\n </div>\n );\n };\n}\n\nfunction shortID(id: string | null | undefined): string {\n if (!id) return \"?\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\nfunction DuelRow({\n match,\n rules,\n index,\n right,\n onPick,\n}: {\n match: PvPMatch;\n rules: DuelRule[];\n index: number;\n right?: ReactNode;\n onPick?: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const e = matchEntry(match);\n const rule = rules.find((r) => r.id === match.RuleID);\n return (\n <div\n onClick={onPick}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Icon glyph=\"swords\" size={30} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {rule?.name ?? match.RuleID} · {t(\"player\")}{\" \"}\n {shortID(match.CreatorID)}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"entry\")}{\" \"}\n {e.currency ? (\n <ResourceList\n lines={[{ kind: \"currency\", id: e.currency, amount: e.amount }]}\n size={16}\n />\n ) : null}\n {match.CreatorCharacterID ? ` · ${match.CreatorCharacterID}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction OpenDuels({\n rules,\n onJoin,\n}: {\n rules: DuelRule[];\n onJoin: (m: PvPMatch) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<PvPMatch[] | null>(null);\n useEffect(() => {\n void client.match\n .getAvailableMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noOpen\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n onPick={() => onJoin(m)}\n right={\n <Button size=\"sm\" tone=\"green\">\n {t(\"fight\")}\n </Button>\n }\n />\n ))}\n </div>\n );\n}\n\nfunction MyDuels({\n rules,\n onChange,\n}: {\n rules: DuelRule[];\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const me = client.auth.context?.userID;\n const [list, setList] = useState<PvPMatch[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.match\n .getMyMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noMine\")} />;\n const cancel = async (id: string) => {\n setBusy(id);\n const res = await client.match.cancelMatch(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n right={\n m.Status === \"Open\" && m.CreatorID === me ? (\n <div style={{ display: \"grid\", gap: 4, justifyItems: \"end\" }}>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n <Button\n size=\"sm\"\n tone=\"grey\"\n busy={busy === m.MatchID}\n onClick={() => void cancel(m.MatchID)}\n >\n {t(\"cancel\")}\n </Button>\n </div>\n ) : m.Status === \"Cancelled\" ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"cancelled\")}\n </span>\n ) : m.Status === \"Completed\" ? (\n <span\n style={{\n ...outlined,\n fontSize: 13,\n color: !m.WinnerUserID\n ? v.textDim\n : m.WinnerUserID === me\n ? v.green\n : v.red,\n }}\n >\n {!m.WinnerUserID\n ? t(\"draw\")\n : m.WinnerUserID === me\n ? t(\"youWon\")\n : t(\"youLost\")}\n </span>\n ) : null\n }\n />\n ))}\n </div>\n );\n}\n\nfunction HeroPicker({\n heroes,\n value,\n onChange,\n}: {\n heroes: Hero[];\n value: string;\n onChange: (id: string) => void;\n}): ReactNode {\n // One hero: nothing to pick, but the player still sees who fights (tabs need two).\n if (heroes.length === 1)\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n <Icon glyph=\"hero\" size={22} /> {heroes[0]!.name} ·{\" \"}\n {formatAmount(heroes[0]!.power)}\n </div>\n );\n return (\n <Tabs\n tabs={heroes.map((h) => ({\n id: h.id,\n label: `${h.name} · ${formatAmount(h.power)}`,\n }))}\n value={value}\n onChange={onChange}\n />\n );\n}\n\nfunction CreatePopup({\n rules,\n heroes,\n onClose,\n onCreated,\n}: {\n rules: DuelRule[];\n heroes: Hero[];\n onClose: () => void;\n onCreated: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [ruleID, setRuleID] = useState(rules[0]!.id);\n const rule = rules.find((r) => r.id === ruleID) ?? rules[0]!;\n const [amount, setAmount] = useState(clampEntry(50, rule));\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = rule.currency\n ? [{ kind: \"currency\" as const, id: rule.currency, amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const create = async () => {\n if (!rule.currency) return;\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.createMatch(\n {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: rule.currency,\n Amount: amount,\n },\n ],\n } as never,\n rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n onCreated();\n onClose();\n };\n return (\n <Popup title={t(\"create\")} onClose={onClose} width={440}>\n {rules.length > 1 ? (\n <Tabs\n tabs={rules.map((r) => ({ id: r.id, label: r.name }))}\n value={ruleID}\n onChange={(id) => {\n setRuleID(id);\n const r = rules.find((x) => x.id === id)!;\n setAmount(clampEntry(amount, r));\n }}\n />\n ) : null}\n {rule.description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {rule.description}\n </div>\n ) : null}\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <Field label={t(\"entry\")}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount - 10, rule))}\n >\n −\n </Button>\n <ResourceList lines={entryLine} size={26} dimIf={() => !affordable} />\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount + 10, rule))}\n >\n +\n </Button>\n </div>\n </Field>\n {rule.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: rule.currency,\n amount: winnerTakes(amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy}\n onClick={() => void create()}\n >\n {t(\"create\")}\n </Button>\n </Popup>\n );\n}\n\nfunction JoinPopup({\n match,\n rule,\n heroes,\n onClose,\n onFought,\n}: {\n match: PvPMatch;\n rule: DuelRule;\n heroes: Hero[];\n onClose: () => void;\n onFought: (r: InstantBattleResponse) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const e = matchEntry(match);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = e.currency\n ? [{ kind: \"currency\" as const, id: e.currency, amount: e.amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const fight = async () => {\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.instantBattle(\n match.MatchID,\n match.RuleID ?? rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onFought(res.data);\n };\n return (\n <Popup\n title={`${rule.name} · ${t(\"player\")} ${shortID(match.CreatorID)}`}\n onClose={onClose}\n width={440}\n >\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"entry\")}\n </div>\n <ResourceList lines={entryLine} size={28} dimIf={() => !affordable} />\n {e.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: e.currency,\n amount: winnerTakes(e.amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n </div>\n <Button\n tone=\"green\"\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void fight()}\n data-tutorial-anchor=\"pvp:fight\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"fight\")}\n </Button>\n </Popup>\n );\n}\n\nconst ZONE_LABEL: Record<Zone, Parameters<typeof t>[0]> = {\n Head: \"head\",\n Torso: \"torso\",\n Legs: \"legs\",\n};\n\nfunction StrategyPopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [steps, setSteps] = useState<Step[]>(() =>\n strategyOrDefault(client.data.user.state?.Match?.PvPBattleStrategy),\n );\n const [busy, setBusy] = useState(false);\n const set = (i: number, key: keyof Step, zone: Zone) =>\n setSteps((s) => s.map((st, j) => (j === i ? { ...st, [key]: zone } : st)));\n const save = async () => {\n setBusy(true);\n const res = await client.match.saveStrategy(steps);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"saved\"));\n onClose();\n };\n const zoneTabs = ZONES.map((z) => ({ id: z, label: t(ZONE_LABEL[z]) }));\n return (\n <Popup title={t(\"strategy\")} onClose={onClose} width={460}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {steps.map((s, i) => (\n <div\n key={i}\n style={{ ...panel, padding: 10, display: \"grid\", gap: 6 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"round\")} {i + 1}\n </span>\n {steps.length > 1 ? (\n <button\n type=\"button\"\n onClick={() => setSteps((st) => st.filter((_, j) => j !== i))}\n style={{\n background: \"none\",\n border: \"none\",\n color: v.textDim,\n cursor: \"pointer\",\n }}\n >\n ✕\n </button>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"attack\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.AttackTarget}\n onChange={(z) => set(i, \"AttackTarget\", z)}\n />\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"defense\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.DefenseTarget}\n onChange={(z) => set(i, \"DefenseTarget\", z)}\n />\n </div>\n ))}\n {steps.length < 10 ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n setSteps((s) => [\n ...s,\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n ])\n }\n >\n {t(\"addRound\")}\n </Button>\n ) : null}\n <Button tone=\"green\" busy={busy} onClick={() => void save()}>\n {t(\"save\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nconst HIT_TEXT = {\n Hit: \"hHit\",\n Critical: \"hCritical\",\n Block: \"hBlock\",\n Dodge: \"hDodge\",\n} as const;\n\nfunction ResultPopup({\n result,\n onClose,\n}: {\n result: InstantBattleResponse;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const me = client.auth.context?.userID;\n const outcome = outcomeFor(result.Battle, me);\n const rounds = roundLines(result.Battle?.BattleLog, me);\n const stagger = useStagger();\n const [shown, setShown] = useState(0);\n const reward =\n result.Battle?.NetReward?.Entries?.filter((e) => e?.CurrencyID).map(\n (e) => ({\n kind: \"currency\" as const,\n id: e!.CurrencyID!,\n amount: Number(e!.Amount ?? 0),\n }),\n ) ?? [];\n useEffect(() => {\n kit.play(\n outcome === \"win\" ? \"levelUp\" : outcome === \"lose\" ? \"error\" : \"notify\",\n );\n }, []);\n // Rounds appear one by one — a short replay of the fight.\n const next = useCallback(\n () => setShown((n) => Math.min(rounds.length, n + 1)),\n [rounds.length],\n );\n useEffect(() => {\n if (shown >= rounds.length) return;\n const id = setTimeout(next, 220);\n return () => clearTimeout(id);\n }, [shown, next, rounds.length]);\n return (\n <Popup\n title={\n outcome === \"win\"\n ? t(\"won\")\n : outcome === \"lose\"\n ? t(\"lost\")\n : t(\"draw\")\n }\n onClose={onClose}\n width={460}\n variant=\"center\"\n >\n <div\n className=\"idos-bounce\"\n style={{ display: \"grid\", justifyItems: \"center\" }}\n >\n <Icon glyph={outcome === \"win\" ? \"trophy\" : \"swords\"} size={64} />\n </div>\n {outcome === \"win\" && reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <ResourceList lines={reward} size={30} />\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"rounds\")}\n </div>\n <div\n className=\"idos-scroll\"\n style={{ display: \"grid\", gap: 4, maxHeight: 260, overflowY: \"auto\" }}\n >\n {rounds.slice(0, shown).map((r, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: \"6px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n fontSize: 13,\n borderColor: r.mine ? v.green : v.red,\n ...stagger(0),\n }}\n >\n <span style={{ ...outlined, minWidth: 28 }}>{r.round}</span>\n <span style={{ ...outlined, color: r.mine ? v.green : v.red }}>\n {r.mine ? t(\"you\") : t(\"foe\")}\n </span>\n <span style={{ flex: 1 }}>\n → {t(ZONE_LABEL[r.zone])}, {t(HIT_TEXT[r.hit])}\n </span>\n <span style={{ ...outlined }}>−{r.damage}</span>\n <span\n style={{ color: v.textDim, minWidth: 54, textAlign: \"right\" }}\n >\n {r.hpLeft} {t(\"hp\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={onClose}>\n OK\n </Button>\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
61
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n InstantBattleResponse,\n MatchDefinitions,\n PvPMatch,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n ZONES,\n clampEntry,\n duelRules,\n matchEntry,\n outcomeFor,\n roundLines,\n strategyOrDefault,\n winnerTakes,\n type DuelRule,\n type Step,\n type Zone,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels (Unity Alikhan/PVP/Lobby): open duels to join and my duels as tabs. Creating a duel\n// (rule, entry, hero), the battle strategy (attack / defence zone per round) and the fight's result\n// round by round are popups. The server fights the battle instantly; the winner takes both entries\n// minus the burn, a draw returns them.\n\ntype Tab = \"open\" | \"mine\";\ntype Hero = { id: string; name: string; power: number };\n\nfunction useHeroes(): Hero[] | null {\n const client = useIDosGamesClient();\n const [heroes, setHeroes] = useState<Hero[] | null>(null);\n useEffect(() => {\n void client.character.getUserCharacters().then((r) => {\n const defs =\n (\n client.data.config.getSection(\"Character\") as\n | {\n Definitions?: Record<\n string,\n { Identity?: { DisplayName?: string | null } | null } | null\n >;\n }\n | undefined\n )?.Definitions ?? {};\n const owned = r.ok ? Object.values(r.data.Characters ?? {}) : [];\n setHeroes(\n owned\n .filter((c) => c?.CharacterID)\n .map((c) => ({\n id: c!.CharacterID!,\n name:\n defs[c!.CharacterID!]?.Identity?.DisplayName ?? c!.CharacterID!,\n power: Number(c!.Power ?? 0),\n }))\n .sort((a, b) => b.power - a.power),\n );\n });\n }, [client]);\n return heroes;\n}\n\nexport function makePvpScreen(features: FeatureRegistry) {\n return function PvpScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<MatchDefinitions | null | undefined>(\n undefined,\n );\n const [tab, setTab] = useState<Tab>(\"open\");\n const [creating, setCreating] = useState(false);\n const [editing, setEditing] = useState(false);\n const [joining, setJoining] = useState<PvPMatch | null>(null);\n const [result, setResult] = useState<InstantBattleResponse | null>(null);\n const [version, setVersion] = useState(0);\n const heroes = useHeroes();\n useEffect(() => {\n void client.match\n .getDefinitions()\n .then((r) => setDefs(r.ok ? r.data : null));\n }, [client]);\n const firstCurrency =\n Object.keys(\n (\n client.data.config.getSection(\"Currency\") as\n { VirtualCurrencies?: Record<string, unknown> } | undefined\n )?.VirtualCurrencies ?? {},\n )[0] ?? null;\n const rules = duelRules(defs as never, firstCurrency);\n const refresh = () => setVersion((n) => n + 1);\n\n if (defs === undefined || !heroes)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (heroes.length === 0)\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <EmptyState glyph=\"swords\" text={t(\"noHero\")} />\n {features.get(\"character\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"character\")}>\n {t(\"toHeroes\")}\n </Button>\n ) : null}\n </div>\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n <Button\n tone=\"gold\"\n attract\n onClick={() => setCreating(true)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"pvp:create\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setEditing(true)}>\n <Icon glyph=\"gear\" size={18} /> {t(\"strategy\")}\n </Button>\n </div>\n <Tabs\n tabs={[\n { id: \"open\", label: t(\"open\") },\n { id: \"mine\", label: t(\"mine\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div key={`${tab}:${version}`}>\n {tab === \"open\" ? (\n <OpenDuels rules={rules} onJoin={setJoining} />\n ) : (\n <MyDuels rules={rules} onChange={refresh} />\n )}\n </div>\n {creating ? (\n <CreatePopup\n rules={rules}\n heroes={heroes}\n onClose={() => setCreating(false)}\n onCreated={() => {\n setTab(\"mine\");\n refresh();\n }}\n />\n ) : null}\n {editing ? <StrategyPopup onClose={() => setEditing(false)} /> : null}\n {joining ? (\n <JoinPopup\n match={joining}\n rule={rules.find((r) => r.id === joining.RuleID) ?? rules[0]!}\n heroes={heroes}\n onClose={() => setJoining(null)}\n onFought={(res) => {\n setJoining(null);\n setResult(res);\n refresh();\n }}\n />\n ) : null}\n {result ? (\n <ResultPopup result={result} onClose={() => setResult(null)} />\n ) : null}\n </div>\n );\n };\n}\n\nfunction shortID(id: string | null | undefined): string {\n if (!id) return \"?\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\nfunction DuelRow({\n match,\n rules,\n index,\n right,\n onPick,\n}: {\n match: PvPMatch;\n rules: DuelRule[];\n index: number;\n right?: ReactNode;\n onPick?: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const e = matchEntry(match);\n const rule = rules.find((r) => r.id === match.RuleID);\n return (\n <div\n onClick={onPick}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Icon glyph=\"swords\" size={30} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {rule?.name ?? match.RuleID} · {t(\"player\")}{\" \"}\n {shortID(match.CreatorID)}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"entry\")}{\" \"}\n {e.currency ? (\n <ResourceList\n lines={[{ kind: \"currency\", id: e.currency, amount: e.amount }]}\n size={16}\n />\n ) : null}\n {match.CreatorCharacterID ? ` · ${match.CreatorCharacterID}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction OpenDuels({\n rules,\n onJoin,\n}: {\n rules: DuelRule[];\n onJoin: (m: PvPMatch) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<PvPMatch[] | null>(null);\n useEffect(() => {\n void client.match\n .getAvailableMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noOpen\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n onPick={() => onJoin(m)}\n right={\n <Button size=\"sm\" tone=\"green\">\n {t(\"fight\")}\n </Button>\n }\n />\n ))}\n </div>\n );\n}\n\nfunction MyDuels({\n rules,\n onChange,\n}: {\n rules: DuelRule[];\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const me = client.auth.context?.userID;\n const [list, setList] = useState<PvPMatch[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.match\n .getMyMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noMine\")} />;\n const cancel = async (id: string) => {\n setBusy(id);\n const res = await client.match.cancelMatch(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n right={\n isExpired(m) ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"expired\")}\n </span>\n ) : m.Status === \"Open\" && m.CreatorID === me ? (\n <div style={{ display: \"grid\", gap: 4, justifyItems: \"end\" }}>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n <Button\n size=\"sm\"\n tone=\"grey\"\n busy={busy === m.MatchID}\n onClick={() => void cancel(m.MatchID)}\n >\n {t(\"cancel\")}\n </Button>\n </div>\n ) : m.Status === \"Cancelled\" ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"cancelled\")}\n </span>\n ) : m.Status === \"Completed\" ? (\n <span\n style={{\n ...outlined,\n fontSize: 13,\n color: !m.WinnerUserID\n ? v.textDim\n : m.WinnerUserID === me\n ? v.green\n : v.red,\n }}\n >\n {!m.WinnerUserID\n ? t(\"draw\")\n : m.WinnerUserID === me\n ? t(\"youWon\")\n : t(\"youLost\")}\n </span>\n ) : null\n }\n />\n ))}\n </div>\n );\n}\n\n/**\n * Срок вышел: либо движок уже вернул взнос (`Expired`), либо срок прошёл, а таймер движка\n * (раз в минуту) ещё не дошёл — принять такой оффер сервер всё равно не даст.\n */\nfunction isExpired(m: PvPMatch): boolean {\n if (m.Status === \"Expired\") return true;\n return (\n m.Status === \"Open\" &&\n !!m.ExpiresAt &&\n new Date(m.ExpiresAt).getTime() <= Date.now()\n );\n}\n\nfunction HeroPicker({\n heroes,\n value,\n onChange,\n}: {\n heroes: Hero[];\n value: string;\n onChange: (id: string) => void;\n}): ReactNode {\n // One hero: nothing to pick, but the player still sees who fights (tabs need two).\n if (heroes.length === 1)\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n <Icon glyph=\"hero\" size={22} /> {heroes[0]!.name} ·{\" \"}\n {formatAmount(heroes[0]!.power)}\n </div>\n );\n return (\n <Tabs\n tabs={heroes.map((h) => ({\n id: h.id,\n label: `${h.name} · ${formatAmount(h.power)}`,\n }))}\n value={value}\n onChange={onChange}\n />\n );\n}\n\nfunction CreatePopup({\n rules,\n heroes,\n onClose,\n onCreated,\n}: {\n rules: DuelRule[];\n heroes: Hero[];\n onClose: () => void;\n onCreated: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [ruleID, setRuleID] = useState(rules[0]!.id);\n const rule = rules.find((r) => r.id === ruleID) ?? rules[0]!;\n const [amount, setAmount] = useState(clampEntry(50, rule));\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = rule.currency\n ? [{ kind: \"currency\" as const, id: rule.currency, amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const create = async () => {\n if (!rule.currency) return;\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.createMatch(\n {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: rule.currency,\n Amount: amount,\n },\n ],\n } as never,\n rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n onCreated();\n onClose();\n };\n return (\n <Popup title={t(\"create\")} onClose={onClose} width={440}>\n {rules.length > 1 ? (\n <Tabs\n tabs={rules.map((r) => ({ id: r.id, label: r.name }))}\n value={ruleID}\n onChange={(id) => {\n setRuleID(id);\n const r = rules.find((x) => x.id === id)!;\n setAmount(clampEntry(amount, r));\n }}\n />\n ) : null}\n {rule.description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {rule.description}\n </div>\n ) : null}\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <Field label={t(\"entry\")}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount - 10, rule))}\n >\n −\n </Button>\n <ResourceList lines={entryLine} size={26} dimIf={() => !affordable} />\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount + 10, rule))}\n >\n +\n </Button>\n </div>\n </Field>\n {rule.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: rule.currency,\n amount: winnerTakes(amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy}\n onClick={() => void create()}\n >\n {t(\"create\")}\n </Button>\n </Popup>\n );\n}\n\nfunction JoinPopup({\n match,\n rule,\n heroes,\n onClose,\n onFought,\n}: {\n match: PvPMatch;\n rule: DuelRule;\n heroes: Hero[];\n onClose: () => void;\n onFought: (r: InstantBattleResponse) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const e = matchEntry(match);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = e.currency\n ? [{ kind: \"currency\" as const, id: e.currency, amount: e.amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const fight = async () => {\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.instantBattle(\n match.MatchID,\n match.RuleID ?? rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onFought(res.data);\n };\n return (\n <Popup\n title={`${rule.name} · ${t(\"player\")} ${shortID(match.CreatorID)}`}\n onClose={onClose}\n width={440}\n >\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"entry\")}\n </div>\n <ResourceList lines={entryLine} size={28} dimIf={() => !affordable} />\n {e.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: e.currency,\n amount: winnerTakes(e.amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n </div>\n <Button\n tone=\"green\"\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void fight()}\n data-tutorial-anchor=\"pvp:fight\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"fight\")}\n </Button>\n </Popup>\n );\n}\n\nconst ZONE_LABEL: Record<Zone, Parameters<typeof t>[0]> = {\n Head: \"head\",\n Torso: \"torso\",\n Legs: \"legs\",\n};\n\nfunction StrategyPopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [steps, setSteps] = useState<Step[]>(() =>\n strategyOrDefault(client.data.user.state?.Match?.PvPBattleStrategy),\n );\n const [busy, setBusy] = useState(false);\n const set = (i: number, key: keyof Step, zone: Zone) =>\n setSteps((s) => s.map((st, j) => (j === i ? { ...st, [key]: zone } : st)));\n const save = async () => {\n setBusy(true);\n const res = await client.match.saveStrategy(steps);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"saved\"));\n onClose();\n };\n const zoneTabs = ZONES.map((z) => ({ id: z, label: t(ZONE_LABEL[z]) }));\n return (\n <Popup title={t(\"strategy\")} onClose={onClose} width={460}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {steps.map((s, i) => (\n <div\n key={i}\n style={{ ...panel, padding: 10, display: \"grid\", gap: 6 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"round\")} {i + 1}\n </span>\n {steps.length > 1 ? (\n <button\n type=\"button\"\n onClick={() => setSteps((st) => st.filter((_, j) => j !== i))}\n style={{\n background: \"none\",\n border: \"none\",\n color: v.textDim,\n cursor: \"pointer\",\n }}\n >\n ✕\n </button>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"attack\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.AttackTarget}\n onChange={(z) => set(i, \"AttackTarget\", z)}\n />\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"defense\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.DefenseTarget}\n onChange={(z) => set(i, \"DefenseTarget\", z)}\n />\n </div>\n ))}\n {steps.length < 10 ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n setSteps((s) => [\n ...s,\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n ])\n }\n >\n {t(\"addRound\")}\n </Button>\n ) : null}\n <Button tone=\"green\" busy={busy} onClick={() => void save()}>\n {t(\"save\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nconst HIT_TEXT = {\n Hit: \"hHit\",\n Critical: \"hCritical\",\n Block: \"hBlock\",\n Dodge: \"hDodge\",\n} as const;\n\nfunction ResultPopup({\n result,\n onClose,\n}: {\n result: InstantBattleResponse;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const me = client.auth.context?.userID;\n const outcome = outcomeFor(result.Battle, me);\n const rounds = roundLines(result.Battle?.BattleLog, me);\n const stagger = useStagger();\n const [shown, setShown] = useState(0);\n const reward =\n result.Battle?.NetReward?.Entries?.filter((e) => e?.CurrencyID).map(\n (e) => ({\n kind: \"currency\" as const,\n id: e!.CurrencyID!,\n amount: Number(e!.Amount ?? 0),\n }),\n ) ?? [];\n useEffect(() => {\n kit.play(\n outcome === \"win\" ? \"levelUp\" : outcome === \"lose\" ? \"error\" : \"notify\",\n );\n }, []);\n // Rounds appear one by one — a short replay of the fight.\n const next = useCallback(\n () => setShown((n) => Math.min(rounds.length, n + 1)),\n [rounds.length],\n );\n useEffect(() => {\n if (shown >= rounds.length) return;\n const id = setTimeout(next, 220);\n return () => clearTimeout(id);\n }, [shown, next, rounds.length]);\n return (\n <Popup\n title={\n outcome === \"win\"\n ? t(\"won\")\n : outcome === \"lose\"\n ? t(\"lost\")\n : t(\"draw\")\n }\n onClose={onClose}\n width={460}\n variant=\"center\"\n >\n <div\n className=\"idos-bounce\"\n style={{ display: \"grid\", justifyItems: \"center\" }}\n >\n <Icon glyph={outcome === \"win\" ? \"trophy\" : \"swords\"} size={64} />\n </div>\n {outcome === \"win\" && reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <ResourceList lines={reward} size={30} />\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"rounds\")}\n </div>\n <div\n className=\"idos-scroll\"\n style={{ display: \"grid\", gap: 4, maxHeight: 260, overflowY: \"auto\" }}\n >\n {rounds.slice(0, shown).map((r, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: \"6px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n fontSize: 13,\n borderColor: r.mine ? v.green : v.red,\n ...stagger(0),\n }}\n >\n <span style={{ ...outlined, minWidth: 28 }}>{r.round}</span>\n <span style={{ ...outlined, color: r.mine ? v.green : v.red }}>\n {r.mine ? t(\"you\") : t(\"foe\")}\n </span>\n <span style={{ flex: 1 }}>\n → {t(ZONE_LABEL[r.zone])}, {t(HIT_TEXT[r.hit])}\n </span>\n <span style={{ ...outlined }}>−{r.damage}</span>\n <span\n style={{ color: v.textDim, minWidth: 54, textAlign: \"right\" }}\n >\n {r.hpLeft} {t(\"hp\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={onClose}>\n OK\n </Button>\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
62
62
  },
63
63
  {
64
64
  "path": "module.meta.json",
@@ -34,12 +34,12 @@
34
34
  "version": "0.1.0"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.20.0",
38
- "@idosgames/module-sdk": "0.8.1",
39
- "@idosgames/react": "0.4.2",
37
+ "@idosgames/core": "0.21.0",
38
+ "@idosgames/module-sdk": "0.8.2",
39
+ "@idosgames/react": "0.4.3",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "7498596d1c779ccadfa57e24b0023652e46c97f5c8657a4ac95ca15b6c8dcc27",
42
+ "contentHash": "29256ddb9360a256d575de79e82791622ab87cd0944a84b34cd97313e58fb8a8",
43
43
  "files": [
44
44
  {
45
45
  "path": "i18n.ts",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  {
61
61
  "path": "QuestsScreen.tsx",
62
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantedBy,\n mergeLines,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableRewards,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests (Unity Runtime/UI/Quests): the title's quests by cycle — daily, weekly, achievements — with\n// the quest points track on top. A quest's goals and reward open in a popup; \"Claim all\" collects\n// every finished quest.\n\nexport function QuestsScreen(_: FeatureScreenProps): ReactNode {\n return <Quests />;\n}\n\nfunction Quests(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = current ? questState?.Cycles?.[current]?.CycleEndUtc : null;\n const ready = shown.filter((r) => r.status === \"Completed\");\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <SectionTitle\n right={<Timer at={cycleEnd} now={now} label={t(\"questsReset\")} />}\n >\n {t(\"quests\")}\n </SectionTitle>\n <Tabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n badge: rows.filter(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ).length,\n }))}\n value={current}\n onChange={setTab}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n onClaimed={load}\n />\n ) : null}\n {ready.length >= 2 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}{\" \"}\n <ResourceList lines={claimableRewards(ready)} size={18} />\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n {shown.map((row, i) => (\n <Card\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n highlight={row.status === \"Completed\"}\n onClick={() => setDetail(row)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n opacity: row.status === \"Claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"quest\" size={38} />\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </div>\n <ProgressBar\n value={row.progress}\n max={row.target}\n tone={row.status === \"Completed\" ? \"gold\" : \"green\"}\n />\n <ResourceList lines={row.reward} />\n </div>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={34} />\n ) : row.status === \"Completed\" ? (\n <Icon glyph=\"gift\" size={34} />\n ) : (\n <span />\n )}\n </Card>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup title={catalog.localize(row.name)} onClose={onClose}>\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"objectives\")}\n </div>\n {row.objectives.map((o) => (\n <div key={o.id} style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={20} /> : null}\n </div>\n <ProgressBar\n value={o.progress}\n max={o.target}\n tone={o.done ? \"gold\" : \"green\"}\n />\n </div>\n ))}\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={row.reward} size={26} gap={14} />\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n {t(\"done\")}\n </div>\n ) : null}\n </Popup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n return (\n <div ref={ref}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n style={{ width: \"100%\" }}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction PointsTrack({\n cycleID,\n section,\n track,\n onClaimed,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState<string | null>(null);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n );\n if (milestones.length === 0) return null;\n const max = milestones[milestones.length - 1]?.required ?? 1;\n const picked = milestones.find((m) => m.id === open);\n\n return (\n <div style={{ ...panel, padding: \"12px 14px\", display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"points\")}</span>\n <span style={{ color: v.gold }}>\n {points} / {max}\n </span>\n </div>\n <div style={{ position: \"relative\", height: 44 }}>\n <div style={{ position: \"absolute\", left: 0, right: 0, top: 16 }}>\n <ProgressBar\n value={Math.min(points, max)}\n max={max}\n tone=\"gold\"\n label=\"\"\n height={12}\n />\n </div>\n {milestones.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n className={`idos-press ${m.state === \"ready\" ? \"idos-bounce\" : \"\"}`}\n onClick={() => setOpen(m.id)}\n aria-label={catalog.localize(m.name)}\n style={{\n position: \"absolute\",\n left: `calc(${(m.required / max) * 100}% - 20px)`,\n top: 0,\n width: 40,\n height: 40,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n opacity: m.state === \"claimed\" ? 0.55 : 1,\n }}\n >\n <Icon glyph={m.state === \"claimed\" ? \"check\" : \"chest\"} size={36} />\n </button>\n ))}\n </div>\n {picked ? (\n <Popup\n title={catalog.localize(picked.name)}\n onClose={() => setOpen(null)}\n width={360}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <Icon glyph=\"chest\" size={72} />\n <ResourceList lines={picked.reward} size={26} gap={14} />\n <ProgressBar\n value={Math.min(points, picked.required)}\n max={picked.required}\n tone=\"gold\"\n />\n </div>\n {picked.state === \"ready\" ? (\n <ClaimMilestone\n onClaim={async () => {\n const res = await client.quest.claimMilestoneReward(\n cycleID,\n picked.id,\n );\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n celebrate(\n grantedBy(res.data).length > 0\n ? grantedBy(res.data)\n : picked.reward,\n );\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </Popup>\n ) : null}\n </div>\n );\n}\n\nfunction ClaimMilestone({\n onClaim,\n}: {\n onClaim: () => Promise<boolean>;\n}): ReactNode {\n const close = usePopupClose();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void onClaim().then((ok) => {\n setBusy(false);\n if (ok) close();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function makeQuestsWatcher(features: FeatureRegistry) {\n return function QuestsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void client.quest.getUserQuestState();\n }, [client]);\n useEffect(() => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n }, [client, state]);\n return null;\n };\n}\n"
62
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantedBy,\n mergeLines,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableRewards,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests (Unity Runtime/UI/Quests): the title's quests by cycle — daily, weekly, achievements — with\n// the quest points track on top. A quest's goals and reward open in a popup; \"Claim all\" collects\n// every finished quest.\n\nexport function QuestsScreen(_: FeatureScreenProps): ReactNode {\n return <Quests />;\n}\n\nfunction Quests(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = current ? questState?.Cycles?.[current]?.CycleEndUtc : null;\n const ready = shown.filter((r) => r.status === \"Completed\");\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <SectionTitle\n right={<Timer at={cycleEnd} now={now} label={t(\"questsReset\")} />}\n >\n {t(\"quests\")}\n </SectionTitle>\n <Tabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n badge: rows.filter(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ).length,\n }))}\n value={current}\n onChange={setTab}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n onClaimed={load}\n />\n ) : null}\n {ready.length >= 2 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}{\" \"}\n <ResourceList lines={claimableRewards(ready)} size={18} />\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n {shown.map((row, i) => (\n <Card\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n highlight={row.status === \"Completed\"}\n onClick={() => setDetail(row)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n opacity: row.status === \"Claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"quest\" size={38} />\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </div>\n <ProgressBar\n value={row.progress}\n max={row.target}\n tone={row.status === \"Completed\" ? \"gold\" : \"green\"}\n />\n <ResourceList lines={row.reward} />\n </div>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={34} />\n ) : row.status === \"Completed\" ? (\n <Icon glyph=\"gift\" size={34} />\n ) : (\n <span />\n )}\n </Card>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup title={catalog.localize(row.name)} onClose={onClose}>\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"objectives\")}\n </div>\n {row.objectives.map((o) => (\n <div key={o.id} style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={20} /> : null}\n </div>\n <ProgressBar\n value={o.progress}\n max={o.target}\n tone={o.done ? \"gold\" : \"green\"}\n />\n </div>\n ))}\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={row.reward} size={26} gap={14} />\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n {t(\"done\")}\n </div>\n ) : null}\n </Popup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n return (\n <div ref={ref}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n style={{ width: \"100%\" }}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction PointsTrack({\n cycleID,\n section,\n track,\n onClaimed,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState<string | null>(null);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n );\n if (milestones.length === 0) return null;\n const max = milestones[milestones.length - 1]?.required ?? 1;\n const picked = milestones.find((m) => m.id === open);\n\n return (\n <div style={{ ...panel, padding: \"12px 14px\", display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"points\")}</span>\n <span style={{ color: v.gold }}>\n {points} / {max}\n </span>\n </div>\n <div style={{ position: \"relative\", height: 44 }}>\n <div style={{ position: \"absolute\", left: 0, right: 0, top: 16 }}>\n <ProgressBar\n value={Math.min(points, max)}\n max={max}\n tone=\"gold\"\n label=\"\"\n height={12}\n />\n </div>\n {milestones.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n className={`idos-press ${m.state === \"ready\" ? \"idos-bounce\" : \"\"}`}\n onClick={() => setOpen(m.id)}\n aria-label={catalog.localize(m.name)}\n style={{\n position: \"absolute\",\n left: `calc(${(m.required / max) * 100}% - 20px)`,\n top: 0,\n width: 40,\n height: 40,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n opacity: m.state === \"claimed\" ? 0.55 : 1,\n }}\n >\n <Icon glyph={m.state === \"claimed\" ? \"check\" : \"chest\"} size={36} />\n </button>\n ))}\n </div>\n {picked ? (\n <Popup\n title={catalog.localize(picked.name)}\n onClose={() => setOpen(null)}\n width={360}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <Icon glyph=\"chest\" size={72} />\n <ResourceList lines={picked.reward} size={26} gap={14} />\n <ProgressBar\n value={Math.min(points, picked.required)}\n max={picked.required}\n tone=\"gold\"\n />\n </div>\n {picked.state === \"ready\" ? (\n <ClaimMilestone\n onClaim={async () => {\n const res = await client.quest.claimMilestoneReward(\n cycleID,\n picked.id,\n );\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n celebrate(\n grantedBy(res.data).length > 0\n ? grantedBy(res.data)\n : picked.reward,\n );\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </Popup>\n ) : null}\n </div>\n );\n}\n\nfunction ClaimMilestone({\n onClaim,\n}: {\n onClaim: () => Promise<boolean>;\n}): ReactNode {\n const close = usePopupClose();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void onClaim().then((ok) => {\n setBusy(false);\n if (ok) close();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function makeQuestsWatcher(features: FeatureRegistry) {\n return function QuestsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // Subscribed directly: the cache patches the quest state in place, so an effect keyed on the\n // state object would not re-run after a claim and the badge would stay lit.\n const update = () => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (client.data.user.state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n // From the login state; the server only when a quest cycle has ended (the central cache's\n // rule for Quest). The check is local, so it runs every minute: a day that turns mid-session\n // brings the new quests (and the badge) without reopening anything.\n const check = () => void client.cache.ensureState([\"Quest\"]);\n check();\n const id = setInterval(check, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n return null;\n };\n}\n"
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",