@idosgames/mcp 0.1.27 → 0.1.29

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 (49) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +11 -71
  4. package/registry/index.json +109 -109
  5. package/registry/modules/ai.json +4 -4
  6. package/registry/modules/board-game.json +3 -3
  7. package/registry/modules/boosts.json +6 -6
  8. package/registry/modules/character.json +33 -5
  9. package/registry/modules/chat.json +25 -5
  10. package/registry/modules/collections.json +22 -6
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +6 -6
  13. package/registry/modules/deals.json +2 -2
  14. package/registry/modules/events.json +5 -5
  15. package/registry/modules/friends.json +25 -5
  16. package/registry/modules/idle-rpg.json +2 -2
  17. package/registry/modules/inventory.json +2 -2
  18. package/registry/modules/invite.json +5 -5
  19. package/registry/modules/leaderboard.json +21 -5
  20. package/registry/modules/lootboxes.json +6 -6
  21. package/registry/modules/mailbox.json +33 -5
  22. package/registry/modules/marketplace.json +34 -6
  23. package/registry/modules/mini-game.json +2 -2
  24. package/registry/modules/multiplayer.json +34 -10
  25. package/registry/modules/premium.json +6 -6
  26. package/registry/modules/profile.json +4 -4
  27. package/registry/modules/pvp.json +33 -5
  28. package/registry/modules/quests.json +5 -5
  29. package/registry/modules/rewards.json +6 -6
  30. package/registry/modules/season.json +5 -5
  31. package/registry/modules/store.json +4 -4
  32. package/registry/modules/tutorials.json +2 -2
  33. package/registry/modules/voxelcraft.json +264 -12
  34. package/registry/modules/workshop.json +49 -5
  35. package/registry/skills/authentication.json +1 -1
  36. package/registry/skills/blockchain-system.json +1 -1
  37. package/registry/skills/checkout-system.json +1 -1
  38. package/registry/skills/cloud-code.json +2 -2
  39. package/registry/skills/currency-system.json +1 -1
  40. package/registry/skills/idosgames-compose-modules.json +1 -1
  41. package/registry/skills/idosgames-getting-started.json +1 -1
  42. package/registry/skills/idosgames-module-contract.json +2 -2
  43. package/registry/skills/idosgames-project-structure.json +1 -1
  44. package/registry/skills/idosgames-ui-kit.json +2 -2
  45. package/registry/skills/marketplace-system.json +1 -1
  46. package/registry/skills/match-system.json +1 -1
  47. package/registry/skills/multiplayer-realtime.json +2 -2
  48. package/registry/skills/voxelcraft-worlds.json +2 -2
  49. package/registry/skills/workshop-system.json +2 -2
@@ -35,16 +35,36 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.21.1",
38
+ "@idosgames/core": "0.21.3",
39
39
  "@idosgames/module-sdk": "0.10.0",
40
- "@idosgames/react": "0.4.4",
40
+ "@idosgames/react": "0.4.5",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "1bd9bec3b0ee8548e800e51bf045b0215da4320231fbf8565350554558f846e4",
43
+ "contentHash": "2b2b0d2308773a87b3d0f410b4a492da63d5dc029e3334c377e77a0dfa047ea1",
44
44
  "files": [
45
45
  {
46
46
  "path": "ChatScreen.tsx",
47
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n ChatCursor,\n ChatReportReason,\n IDosGamesClient,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n errorText,\n Icon,\n IconButton,\n outlined,\n panel,\n Popup,\n Spinner,\n Tabs,\n toneSurface,\n useCatalog,\n useNow,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n authorName,\n channelChoices,\n chatBadge,\n isSystem,\n lastCursor,\n mergeMessages,\n orderedChannels,\n sendable,\n slowModeLeft,\n unreadLabel,\n type ChannelView,\n type MessageView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chat (Unity V2/Runtime/UI/Chat): the title's channels as tabs plus \"Private\" (1-on-1 chats with\n// friends). One poll covers every room and runs ONLY while this screen is open, at the interval the\n// server sets; rooms are marked read when the player leaves them. Message actions (report, ignore,\n// write privately) and joining / leaving channels are popups. Another module opens a private chat\n// with features.open(\"chat\", { userID }).\n\ntype Room =\n | { kind: \"channel\"; channel: ChannelView }\n | { kind: \"direct\"; roomID: string; peerName: string };\ntype Conversation = {\n RoomID?: string | null;\n PeerUserID?: string | null;\n PeerProfile?: { Username?: string | null } | null;\n UnreadCount?: number | null;\n LastMessage?: MessageView | null;\n};\nconst DIRECT = \"__direct\";\ntype ChatDefs = {\n Direct?: { IsEnabled?: boolean | null } | null;\n Channels?: Parameters<typeof channelChoices>[0];\n};\n\n/** Badge once per session (a closed chat costs nothing: no background polling). */\nexport function makeChatWatcher(features: FeatureRegistry) {\n return function ChatWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // The same read gives the badge and whether the title has a chat at all: switched off, or\n // no channels — the lobby hides the tab.\n void client.chat.getChannels().then((r) => {\n const channels = r.ok ? (r.data.Channels ?? []) : [];\n features.setAvailable(\n \"chat\",\n channels.length > 0,\n r.ok\n ? \"the title's chat has no channels (Chat section)\"\n : `the chat is not available on the title (${r.error})`,\n );\n features.setBadge(\"chat\", chatBadge(channels));\n });\n }, [client]);\n return null;\n };\n}\n\nexport function makeChatScreen(features: FeatureRegistry) {\n return function ChatScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [channels, setChannels] = useState<ChannelView[] | null>(null);\n const [interval, setIntervalSec] = useState(5);\n const [mutedUntil, setMutedUntil] = useState<string | null>(null);\n const [tab, setTab] = useState<string | null>(null);\n const [room, setRoom] = useState<Room | null>(null);\n const [feeds, setFeeds] = useState<Record<string, MessageView[]>>({});\n const [unread, setUnread] = useState<Record<string, number | null>>({});\n const [managing, setManaging] = useState(false);\n // The Chat section is not part of the client config — the definitions come from their own call.\n const [defs, setDefs] = useState<ChatDefs | null>(null);\n const roomRef = useRef<Room | null>(null);\n roomRef.current = room;\n\n const loadChannels = useCallback(async () => {\n const res = await client.chat.getChannels();\n if (!res.ok) return setChannels([]);\n const list = orderedChannels(res.data.Channels ?? []);\n setChannels(list);\n setIntervalSec(Math.max(2, Number(res.data.PollIntervalSeconds ?? 5)));\n setMutedUntil(res.data.MutedUntil ?? null);\n setUnread(\n Object.fromEntries(\n list.map((c) => [c.RoomID ?? \"\", c.UnreadCount ?? null]),\n ),\n );\n features.setBadge(\"chat\", 0);\n return list;\n }, [client]);\n\n useEffect(() => {\n void client.chat\n .getChatDefinitions()\n .then((r) => setDefs(r.ok ? (r.data as ChatDefs) : {}));\n }, [client]);\n\n useEffect(() => {\n void loadChannels().then((list) => {\n const target = (args as { userID?: string } | undefined)?.userID;\n if (target)\n return void openDirect(client, target, toast, (r) => {\n setTab(DIRECT);\n setRoom(r);\n });\n const first = list?.[0];\n if (first) {\n setTab(first.ChannelID ?? null);\n setRoom({ kind: \"channel\", channel: first });\n }\n });\n }, [loadChannels]);\n\n // One poll for every room, only while the screen is open.\n useEffect(() => {\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const tick = async () => {\n const res = await client.chat.poll();\n if (stopped) return;\n if (res.ok) {\n const current = roomID(roomRef.current);\n for (const r of res.data.Rooms ?? []) {\n if (!r.RoomID) continue;\n const msgs = r.Messages ?? [];\n if (msgs.length > 0)\n setFeeds((f) => ({\n ...f,\n [r.RoomID!]: mergeMessages(f[r.RoomID!] ?? [], msgs),\n }));\n if (r.RoomID !== current)\n setUnread((u) => ({\n ...u,\n [r.RoomID!]: r.UnreadCount ?? u[r.RoomID!] ?? null,\n }));\n }\n if (res.data.PollIntervalSeconds)\n setIntervalSec(Math.max(2, res.data.PollIntervalSeconds));\n }\n timer = setTimeout(() => void tick(), interval * 1000);\n };\n timer = setTimeout(() => void tick(), interval * 1000);\n return () => {\n stopped = true;\n if (timer) clearTimeout(timer);\n };\n }, [client, interval]);\n\n if (!channels || !defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const directEnabled = !!defs.Direct?.IsEnabled;\n if (channels.length === 0 && !directEnabled)\n return <EmptyState glyph=\"chat\" text={t(\"disabled\")} />;\n\n const pick = (id: string) => {\n setTab(id);\n if (id === DIRECT) return setRoom(null);\n const ch = channels.find((c) => c.ChannelID === id);\n if (ch) setRoom({ kind: \"channel\", channel: ch });\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs\n tabs={[\n ...channels.map((c) => ({\n id: c.ChannelID!,\n label: catalog.localize(c.DisplayNameKey ?? c.ChannelID),\n badge:\n Number(\n unreadLabel(unread[c.RoomID ?? \"\"]).replace(\"+\", \"\"),\n ) || undefined,\n })),\n ...(directEnabled ? [{ id: DIRECT, label: t(\"direct\") }] : []),\n ]}\n value={tab ?? \"\"}\n onChange={pick}\n />\n </div>\n <IconButton\n glyph=\"gear\"\n label={t(\"channels\")}\n onClick={() => setManaging(true)}\n size={36}\n tone=\"blue\"\n />\n </div>\n {mutedUntil && new Date(mutedUntil).getTime() > Date.now() ? (\n <div\n style={{\n ...panel,\n padding: 8,\n ...outlined,\n fontSize: 13,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"muted\")} {new Date(mutedUntil).toLocaleString()}\n </div>\n ) : null}\n {tab === DIRECT && !room ? (\n <Conversations onOpen={(r) => setRoom(r)} />\n ) : room ? (\n <RoomView\n key={roomID(room)}\n room={room}\n feed={feeds[roomID(room)] ?? []}\n setFeed={(fn) =>\n setFeeds((f) => ({\n ...f,\n [roomID(room)]: fn(f[roomID(room)] ?? []),\n }))\n }\n onBack={room.kind === \"direct\" ? () => setRoom(null) : undefined}\n onOpenDirect={(r) => {\n setTab(DIRECT);\n setRoom(r);\n }}\n directEnabled={directEnabled}\n />\n ) : null}\n {managing ? (\n <ChannelsPopup\n defs={defs}\n joined={channels.map((c) => c.ChannelID ?? \"\")}\n onClose={() => setManaging(false)}\n onChanged={() => void loadChannels()}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction roomID(room: Room | null): string {\n if (!room) return \"\";\n return room.kind === \"channel\" ? (room.channel.RoomID ?? \"\") : room.roomID;\n}\n\nasync function openDirect(\n client: IDosGamesClient,\n userID: string,\n toast: (text: string, kind?: \"error\") => void,\n open: (r: Room) => void,\n): Promise<void> {\n const res = await client.chat.openDirectChannel(userID);\n if (!res.ok || !res.data.RoomID)\n return toast(errorText(res.ok ? \"\" : res.error), \"error\");\n open({\n kind: \"direct\",\n roomID: res.data.RoomID,\n peerName: res.data.PeerProfile?.Username ?? userID,\n });\n}\n\nfunction RoomView({\n room,\n feed,\n setFeed,\n onBack,\n onOpenDirect,\n directEnabled,\n}: {\n room: Room;\n feed: MessageView[];\n setFeed: (fn: (prev: MessageView[]) => MessageView[]) => void;\n onBack?: () => void;\n onOpenDirect: (r: Room) => void;\n directEnabled: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const id = roomID(room);\n const [older, setOlder] = useState<ChatCursor | null | undefined>(undefined);\n const [loading, setLoading] = useState(true);\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [lastSent, setLastSent] = useState<number | null>(null);\n const [picked, setPicked] = useState<MessageView | null>(null);\n const feedRef = useRef<HTMLDivElement>(null);\n const feedNow = useRef(feed);\n feedNow.current = feed;\n const me = client.auth.context?.userID;\n const channel = room.kind === \"channel\" ? room.channel : null;\n const maxLength = channel?.MaxLength ?? 500;\n\n // History first, then mark read up to what was drawn; on leaving, read up to the last message.\n useEffect(() => {\n let alive = true;\n void client.chat.getHistory(id).then((res) => {\n if (!alive) return;\n setLoading(false);\n if (!res.ok) return;\n const merged = mergeMessages(feedNow.current, res.data.Messages ?? []);\n setFeed(() => merged);\n setOlder(res.data.NextBefore ?? null);\n const cursor = lastCursor(id, merged);\n if (cursor) void client.chat.markRead(id, cursor);\n });\n return () => {\n alive = false;\n const cursor = lastCursor(id, feedNow.current);\n if (cursor) void client.chat.markRead(id, cursor);\n };\n }, [client, id]);\n\n // Keep the newest message in view.\n useEffect(() => {\n const el = feedRef.current;\n if (el) el.scrollTop = el.scrollHeight;\n }, [feed.length]);\n\n const loadOlder = async () => {\n if (!older) return;\n const res = await client.chat.getHistory(id, older);\n if (!res.ok) return;\n setFeed((prev) => mergeMessages(prev, res.data.Messages ?? []));\n setOlder(res.data.NextBefore ?? null);\n };\n\n const wait = slowModeLeft(lastSent, channel?.SlowModeSeconds, now);\n const message = sendable(text, maxLength);\n const canWrite = channel ? channel.CanWrite !== false : true;\n\n const send = async () => {\n if (!message || wait > 0) return;\n setBusy(true);\n const res = channel\n ? await client.chat.sendMessage(channel.ChannelID!, message)\n : await client.chat.sendDirect(id, message);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"tab\");\n setText(\"\");\n setLastSent(Date.now());\n // Render the SERVER's copy: moderation may have masked a word.\n if (res.data.Message)\n setFeed((prev) => mergeMessages(prev, [res.data.Message!]));\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gap: 8,\n height: \"min(70vh, 640px)\",\n minHeight: 320,\n }}\n >\n {onBack ? (\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Button size=\"sm\" tone=\"grey\" onClick={onBack}>\n ← {t(\"back\")}\n </Button>\n <span style={{ ...outlined, fontSize: 15 }}>\n {room.kind === \"direct\" ? room.peerName : \"\"}\n </span>\n </div>\n ) : (\n <div />\n )}\n <div\n ref={feedRef}\n className=\"idos-scroll\"\n style={{\n ...panel,\n padding: 10,\n overflowY: \"auto\",\n display: \"grid\",\n alignContent: \"start\",\n gap: 6,\n }}\n >\n {older ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => void loadOlder()}\n style={{ justifySelf: \"center\" }}\n >\n {t(\"older\")}\n </Button>\n ) : null}\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : feed.length === 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n padding: 20,\n }}\n >\n {t(\"noMessages\")}\n </div>\n ) : null}\n {feed.map((m) => {\n const mine = m.SenderUserID === me;\n const system = isSystem(m);\n return (\n <div\n key={m.MessageID}\n style={{\n display: \"flex\",\n justifyContent: system\n ? \"center\"\n : mine\n ? \"flex-end\"\n : \"flex-start\",\n }}\n >\n <div\n onClick={!mine && !system ? () => setPicked(m) : undefined}\n style={{\n maxWidth: \"80%\",\n padding: \"6px 10px\",\n borderRadius: 12,\n ...(system\n ? {\n background: `color-mix(in srgb, ${v.gold} 18%, transparent)`,\n }\n : mine\n ? toneSurface(\"blue\")\n : {\n background: `color-mix(in srgb, ${v.text} 10%, transparent)`,\n }),\n cursor: !mine && !system ? \"pointer\" : undefined,\n overflowWrap: \"anywhere\",\n }}\n >\n {!mine ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: system ? v.gold : v.textDim,\n }}\n >\n {system ? t(\"system\") : authorName(m)}\n </div>\n ) : null}\n <div style={{ fontSize: 14, fontWeight: 600 }}>{m.Text}</div>\n <div\n style={{\n fontSize: 10,\n color: v.textDim,\n textAlign: \"right\",\n }}\n >\n {m.SentAt\n ? new Date(m.SentAt).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"\"}\n </div>\n </div>\n </div>\n );\n })}\n </div>\n {canWrite ? (\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={maxLength}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n placeholder={t(\"placeholder\")}\n style={{\n ...panel,\n flex: 1,\n minWidth: 0,\n padding: \"10px 12px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n }}\n data-tutorial-anchor=\"chat:input\"\n />\n <Button\n tone=\"green\"\n disabled={!message || wait > 0}\n busy={busy}\n onClick={() => void send()}\n >\n {wait > 0 ? `${wait}${t(\"sec\")}` : <Icon glyph=\"chat\" size={20} />}\n </Button>\n </div>\n ) : (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {channel?.WriteBlockedReason ?? \"—\"}\n </div>\n )}\n {picked ? (\n <MessagePopup\n message={picked}\n roomID={id}\n directEnabled={directEnabled}\n onOpenDirect={onOpenDirect}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Conversations({ onOpen }: { onOpen: (r: Room) => void }): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<Conversation[] | null>(null);\n useEffect(() => {\n void client.chat\n .getConversations()\n .then((r) =>\n setList(r.ok ? ((r.data.Conversations ?? []) as Conversation[]) : []),\n );\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"chat\" text={t(\"noConversations\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((c) => {\n const name = c.PeerProfile?.Username ?? c.PeerUserID ?? \"?\";\n return (\n <div\n key={c.RoomID}\n onClick={() =>\n c.RoomID &&\n onOpen({ kind: \"direct\", roomID: c.RoomID, peerName: name })\n }\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n }}\n >\n <Icon glyph=\"user\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{name}</div>\n <div\n style={{\n fontSize: 12,\n color: v.textDim,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {c.LastMessage?.Text ?? \"\"}\n </div>\n </div>\n {unreadLabel(c.UnreadCount) ? (\n <span\n style={{\n ...outlined,\n fontSize: 12,\n background: v.red,\n borderRadius: 10,\n padding: \"2px 7px\",\n }}\n >\n {unreadLabel(c.UnreadCount)}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n\nconst REASONS: Array<{ id: ChatReportReason; label: Parameters<typeof t>[0] }> =\n [\n { id: \"Spam\", label: \"rSpam\" },\n { id: \"Abuse\", label: \"rAbuse\" },\n { id: \"Scam\", label: \"rScam\" },\n { id: \"Inappropriate\", label: \"rInappropriate\" },\n { id: \"Other\", label: \"rOther\" },\n ];\n\nfunction MessagePopup({\n message,\n roomID,\n directEnabled,\n onOpenDirect,\n onClose,\n}: {\n message: MessageView;\n roomID: string;\n directEnabled: boolean;\n onOpenDirect: (r: Room) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [reason, setReason] = useState<ChatReportReason>(\"Spam\");\n const [busy, setBusy] = useState<string | null>(null);\n const sender = message.SenderUserID!;\n const run = async (what: string, fn: () => Promise<void>) => {\n setBusy(what);\n await fn();\n setBusy(null);\n };\n return (\n <Popup title={authorName(message)} onClose={onClose} width={420}>\n <div\n style={{\n ...panel,\n padding: 10,\n fontSize: 14,\n fontWeight: 600,\n overflowWrap: \"anywhere\",\n }}\n >\n {message.Text}\n </div>\n {directEnabled ? (\n <Button\n tone=\"blue\"\n busy={busy === \"dm\"}\n onClick={() =>\n void run(\"dm\", async () => {\n await openDirect(client, sender, toast, (r) => {\n onOpenDirect(r);\n onClose();\n });\n })\n }\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"writePrivately\")}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"reason\")}\n </div>\n <Tabs\n tabs={REASONS.map((r) => ({ id: r.id, label: t(r.label) }))}\n value={reason}\n onChange={setReason}\n />\n <Button\n tone=\"red\"\n busy={busy === \"report\"}\n onClick={() =>\n void run(\"report\", async () => {\n const res = await client.chat.reportMessage(\n roomID,\n message.MessageID!,\n reason,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(\n (res.data as { AlreadyReported?: boolean | null })\n .AlreadyReported\n ? t(\"alreadyReported\")\n : t(\"reported\"),\n );\n onClose();\n })\n }\n >\n {t(\"report\")}\n </Button>\n </div>\n <Button\n tone=\"grey\"\n busy={busy === \"ignore\"}\n onClick={() =>\n void run(\"ignore\", async () => {\n const res = await client.chat.ignoreUser(sender);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"ignored\"));\n onClose();\n })\n }\n >\n {t(\"ignore\")}\n </Button>\n </Popup>\n );\n}\n\nfunction ChannelsPopup({\n defs,\n joined,\n onClose,\n onChanged,\n}: {\n defs: ChatDefs;\n joined: string[];\n onClose: () => void;\n onChanged: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const [ids, setIds] = useState(joined);\n const choices = channelChoices(defs.Channels, ids);\n const toggle = async (id: string, join: boolean) => {\n setBusy(id);\n const res = join\n ? await client.chat.joinChannel(id)\n : await client.chat.leaveChannel(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setIds((prev) => (join ? [...prev, id] : prev.filter((x) => x !== id)));\n onChanged();\n };\n return (\n <Popup title={t(\"channels\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {choices.map((c) => (\n <div\n key={c.id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"chat\" size={24} />\n <div style={{ flex: 1, ...outlined, fontSize: 15 }}>\n {catalog.localize(c.name)}\n </div>\n <Button\n size=\"sm\"\n tone={c.joined ? \"grey\" : \"green\"}\n busy={busy === c.id}\n onClick={() => void toggle(c.id, !c.joined)}\n >\n {c.joined ? t(\"leave\") : t(\"join\")}\n </Button>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
47
+ "content": "import {\n type ModuleContext,\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Center,\n EmptyState,\n IconButton,\n outlined,\n panel,\n Spinner,\n Tabs,\n useCatalog,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n chatBadge,\n mergeMessages,\n orderedChannels,\n unreadLabel,\n type ChannelView,\n type MessageView,\n} from \"./model\";\nimport { t } from \"./i18n\";\nimport { ChannelsPopup } from \"./components/ChannelsPopup\";\nimport { Conversations } from \"./components/Conversations\";\nimport { RoomView } from \"./components/RoomView\";\nimport {\n type ChatDefs,\n DIRECT,\n type Room,\n openDirect,\n roomID,\n} from \"./helpers\";\n\n// Chat (Unity V2/Runtime/UI/Chat): the title's channels as tabs plus \"Private\" (1-on-1 chats with\n// friends). One poll covers every room and runs ONLY while this screen is open, at the interval the\n// server sets; rooms are marked read when the player leaves them. Message actions (report, ignore,\n// write privately) and joining / leaving channels are popups. Another module opens a private chat\n// with features.open(\"chat\", { userID }).\n\n/** Badge once per session (a closed chat costs nothing: no background polling). */\nexport function watchChat(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // The same read gives the badge and whether the title has a chat at all: switched off, or\n // no channels — the lobby hides the tab.\n void client.chat.getChannels().then((r) => {\n const channels = r.ok ? (r.data.Channels ?? []) : [];\n features.setAvailable(\n \"chat\",\n channels.length > 0,\n r.ok\n ? \"the title's chat has no channels (Chat section)\"\n : `the chat is not available on the title (${r.error})`,\n );\n features.setBadge(\"chat\", chatBadge(channels));\n });\n });\n}\n\nexport function makeChatScreen(features: FeatureRegistry) {\n return function ChatScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [channels, setChannels] = useState<ChannelView[] | null>(null);\n const [interval, setIntervalSec] = useState(5);\n const [mutedUntil, setMutedUntil] = useState<string | null>(null);\n const [tab, setTab] = useState<string | null>(null);\n const [room, setRoom] = useState<Room | null>(null);\n const [feeds, setFeeds] = useState<Record<string, MessageView[]>>({});\n const [unread, setUnread] = useState<Record<string, number | null>>({});\n const [managing, setManaging] = useState(false);\n // The Chat section is not part of the client config — the definitions come from their own call.\n const [defs, setDefs] = useState<ChatDefs | null>(null);\n const roomRef = useRef<Room | null>(null);\n roomRef.current = room;\n\n const loadChannels = useCallback(async () => {\n const res = await client.chat.getChannels();\n if (!res.ok) return setChannels([]);\n const list = orderedChannels(res.data.Channels ?? []);\n setChannels(list);\n setIntervalSec(Math.max(2, Number(res.data.PollIntervalSeconds ?? 5)));\n setMutedUntil(res.data.MutedUntil ?? null);\n setUnread(\n Object.fromEntries(\n list.map((c) => [c.RoomID ?? \"\", c.UnreadCount ?? null]),\n ),\n );\n features.setBadge(\"chat\", 0);\n return list;\n }, [client]);\n\n useEffect(() => {\n void client.chat\n .getChatDefinitions()\n .then((r) => setDefs(r.ok ? (r.data as ChatDefs) : {}));\n }, [client]);\n\n useEffect(() => {\n void loadChannels().then((list) => {\n const target = (args as { userID?: string } | undefined)?.userID;\n if (target)\n return void openDirect(client, target, toast, (r) => {\n setTab(DIRECT);\n setRoom(r);\n });\n const first = list?.[0];\n if (first) {\n setTab(first.ChannelID ?? null);\n setRoom({ kind: \"channel\", channel: first });\n }\n });\n }, [loadChannels]);\n\n // One poll for every room, only while the screen is open.\n useEffect(() => {\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const tick = async () => {\n const res = await client.chat.poll();\n if (stopped) return;\n if (res.ok) {\n const current = roomID(roomRef.current);\n for (const r of res.data.Rooms ?? []) {\n if (!r.RoomID) continue;\n const msgs = r.Messages ?? [];\n if (msgs.length > 0)\n setFeeds((f) => ({\n ...f,\n [r.RoomID!]: mergeMessages(f[r.RoomID!] ?? [], msgs),\n }));\n if (r.RoomID !== current)\n setUnread((u) => ({\n ...u,\n [r.RoomID!]: r.UnreadCount ?? u[r.RoomID!] ?? null,\n }));\n }\n if (res.data.PollIntervalSeconds)\n setIntervalSec(Math.max(2, res.data.PollIntervalSeconds));\n }\n timer = setTimeout(() => void tick(), interval * 1000);\n };\n timer = setTimeout(() => void tick(), interval * 1000);\n return () => {\n stopped = true;\n if (timer) clearTimeout(timer);\n };\n }, [client, interval]);\n\n if (!channels || !defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const directEnabled = !!defs.Direct?.IsEnabled;\n if (channels.length === 0 && !directEnabled)\n return <EmptyState glyph=\"chat\" text={t(\"disabled\")} />;\n\n const pick = (id: string) => {\n setTab(id);\n if (id === DIRECT) return setRoom(null);\n const ch = channels.find((c) => c.ChannelID === id);\n if (ch) setRoom({ kind: \"channel\", channel: ch });\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs\n tabs={[\n ...channels.map((c) => ({\n id: c.ChannelID!,\n label: catalog.localize(c.DisplayNameKey ?? c.ChannelID),\n badge:\n Number(\n unreadLabel(unread[c.RoomID ?? \"\"]).replace(\"+\", \"\"),\n ) || undefined,\n })),\n ...(directEnabled ? [{ id: DIRECT, label: t(\"direct\") }] : []),\n ]}\n value={tab ?? \"\"}\n onChange={pick}\n />\n </div>\n <IconButton\n glyph=\"gear\"\n label={t(\"channels\")}\n onClick={() => setManaging(true)}\n size={36}\n tone=\"blue\"\n />\n </div>\n {mutedUntil && new Date(mutedUntil).getTime() > Date.now() ? (\n <div\n style={{\n ...panel,\n padding: 8,\n ...outlined,\n fontSize: 13,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"muted\")} {new Date(mutedUntil).toLocaleString()}\n </div>\n ) : null}\n {tab === DIRECT && !room ? (\n <Conversations onOpen={(r) => setRoom(r)} />\n ) : room ? (\n <RoomView\n key={roomID(room)}\n room={room}\n feed={feeds[roomID(room)] ?? []}\n setFeed={(fn) =>\n setFeeds((f) => ({\n ...f,\n [roomID(room)]: fn(f[roomID(room)] ?? []),\n }))\n }\n onBack={room.kind === \"direct\" ? () => setRoom(null) : undefined}\n onOpenDirect={(r) => {\n setTab(DIRECT);\n setRoom(r);\n }}\n directEnabled={directEnabled}\n />\n ) : null}\n {managing ? (\n <ChannelsPopup\n defs={defs}\n joined={channels.map((c) => c.ChannelID ?? \"\")}\n onClose={() => setManaging(false)}\n onChanged={() => void loadChannels()}\n />\n ) : null}\n </div>\n );\n };\n}\n"
48
+ },
49
+ {
50
+ "path": "components/ChannelsPopup.tsx",
51
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n errorText,\n Icon,\n outlined,\n panel,\n Popup,\n useCatalog,\n useToast,\n} from \"@idosgames/react/ui\";\nimport { channelChoices } from \"../model\";\nimport { t } from \"../i18n\";\nimport { type ChatDefs } from \"../helpers\";\n\nexport function ChannelsPopup({\n defs,\n joined,\n onClose,\n onChanged,\n}: {\n defs: ChatDefs;\n joined: string[];\n onClose: () => void;\n onChanged: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const [ids, setIds] = useState(joined);\n const choices = channelChoices(defs.Channels, ids);\n const toggle = async (id: string, join: boolean) => {\n setBusy(id);\n const res = join\n ? await client.chat.joinChannel(id)\n : await client.chat.leaveChannel(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setIds((prev) => (join ? [...prev, id] : prev.filter((x) => x !== id)));\n onChanged();\n };\n return (\n <Popup title={t(\"channels\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {choices.map((c) => (\n <div\n key={c.id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"chat\" size={24} />\n <div style={{ flex: 1, ...outlined, fontSize: 15 }}>\n {catalog.localize(c.name)}\n </div>\n <Button\n size=\"sm\"\n tone={c.joined ? \"grey\" : \"green\"}\n busy={busy === c.id}\n onClick={() => void toggle(c.id, !c.joined)}\n >\n {c.joined ? t(\"leave\") : t(\"join\")}\n </Button>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
52
+ },
53
+ {
54
+ "path": "components/Conversations.tsx",
55
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Center,\n EmptyState,\n Icon,\n outlined,\n panel,\n Spinner,\n v,\n} from \"@idosgames/react/ui\";\nimport { unreadLabel } from \"../model\";\nimport { t } from \"../i18n\";\nimport { type Conversation, type Room } from \"../helpers\";\n\nexport function Conversations({\n onOpen,\n}: {\n onOpen: (r: Room) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<Conversation[] | null>(null);\n useEffect(() => {\n void client.chat\n .getConversations()\n .then((r) =>\n setList(r.ok ? ((r.data.Conversations ?? []) as Conversation[]) : []),\n );\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"chat\" text={t(\"noConversations\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((c) => {\n const name = c.PeerProfile?.Username ?? c.PeerUserID ?? \"?\";\n return (\n <div\n key={c.RoomID}\n onClick={() =>\n c.RoomID &&\n onOpen({ kind: \"direct\", roomID: c.RoomID, peerName: name })\n }\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n }}\n >\n <Icon glyph=\"user\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{name}</div>\n <div\n style={{\n fontSize: 12,\n color: v.textDim,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {c.LastMessage?.Text ?? \"\"}\n </div>\n </div>\n {unreadLabel(c.UnreadCount) ? (\n <span\n style={{\n ...outlined,\n fontSize: 12,\n background: v.red,\n borderRadius: 10,\n padding: \"2px 7px\",\n }}\n >\n {unreadLabel(c.UnreadCount)}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n"
56
+ },
57
+ {
58
+ "path": "components/MessagePopup.tsx",
59
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { type ChatReportReason } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n errorText,\n Icon,\n outlined,\n panel,\n Popup,\n Tabs,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { authorName, type MessageView } from \"../model\";\nimport { t } from \"../i18n\";\nimport { type Room, openDirect } from \"../helpers\";\n\nconst REASONS: Array<{ id: ChatReportReason; label: Parameters<typeof t>[0] }> =\n [\n { id: \"Spam\", label: \"rSpam\" },\n { id: \"Abuse\", label: \"rAbuse\" },\n { id: \"Scam\", label: \"rScam\" },\n { id: \"Inappropriate\", label: \"rInappropriate\" },\n { id: \"Other\", label: \"rOther\" },\n ];\n\nexport function MessagePopup({\n message,\n roomID,\n directEnabled,\n onOpenDirect,\n onClose,\n}: {\n message: MessageView;\n roomID: string;\n directEnabled: boolean;\n onOpenDirect: (r: Room) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [reason, setReason] = useState<ChatReportReason>(\"Spam\");\n const [busy, setBusy] = useState<string | null>(null);\n const sender = message.SenderUserID!;\n const run = async (what: string, fn: () => Promise<void>) => {\n setBusy(what);\n await fn();\n setBusy(null);\n };\n return (\n <Popup title={authorName(message)} onClose={onClose} width={420}>\n <div\n style={{\n ...panel,\n padding: 10,\n fontSize: 14,\n fontWeight: 600,\n overflowWrap: \"anywhere\",\n }}\n >\n {message.Text}\n </div>\n {directEnabled ? (\n <Button\n tone=\"blue\"\n busy={busy === \"dm\"}\n onClick={() =>\n void run(\"dm\", async () => {\n await openDirect(client, sender, toast, (r) => {\n onOpenDirect(r);\n onClose();\n });\n })\n }\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"writePrivately\")}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"reason\")}\n </div>\n <Tabs\n tabs={REASONS.map((r) => ({ id: r.id, label: t(r.label) }))}\n value={reason}\n onChange={setReason}\n />\n <Button\n tone=\"red\"\n busy={busy === \"report\"}\n onClick={() =>\n void run(\"report\", async () => {\n const res = await client.chat.reportMessage(\n roomID,\n message.MessageID!,\n reason,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(\n (res.data as { AlreadyReported?: boolean | null })\n .AlreadyReported\n ? t(\"alreadyReported\")\n : t(\"reported\"),\n );\n onClose();\n })\n }\n >\n {t(\"report\")}\n </Button>\n </div>\n <Button\n tone=\"grey\"\n busy={busy === \"ignore\"}\n onClick={() =>\n void run(\"ignore\", async () => {\n const res = await client.chat.ignoreUser(sender);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"ignored\"));\n onClose();\n })\n }\n >\n {t(\"ignore\")}\n </Button>\n </Popup>\n );\n}\n"
60
+ },
61
+ {
62
+ "path": "components/RoomView.tsx",
63
+ "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { type ChatCursor } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n errorText,\n Icon,\n outlined,\n panel,\n Spinner,\n TextInput,\n toneSurface,\n useNow,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n authorName,\n isSystem,\n lastCursor,\n mergeMessages,\n sendable,\n slowModeLeft,\n type MessageView,\n} from \"../model\";\nimport { t } from \"../i18n\";\nimport { MessagePopup } from \"./MessagePopup\";\nimport { type Room, roomID } from \"../helpers\";\n\nexport function RoomView({\n room,\n feed,\n setFeed,\n onBack,\n onOpenDirect,\n directEnabled,\n}: {\n room: Room;\n feed: MessageView[];\n setFeed: (fn: (prev: MessageView[]) => MessageView[]) => void;\n onBack?: () => void;\n onOpenDirect: (r: Room) => void;\n directEnabled: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const id = roomID(room);\n const [older, setOlder] = useState<ChatCursor | null | undefined>(undefined);\n const [loading, setLoading] = useState(true);\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [lastSent, setLastSent] = useState<number | null>(null);\n const [picked, setPicked] = useState<MessageView | null>(null);\n const feedRef = useRef<HTMLDivElement>(null);\n const feedNow = useRef(feed);\n feedNow.current = feed;\n const me = client.auth.context?.userID;\n const channel = room.kind === \"channel\" ? room.channel : null;\n const maxLength = channel?.MaxLength ?? 500;\n\n // History first, then mark read up to what was drawn; on leaving, read up to the last message.\n useEffect(() => {\n let alive = true;\n void client.chat.getHistory(id).then((res) => {\n if (!alive) return;\n setLoading(false);\n if (!res.ok) return;\n const merged = mergeMessages(feedNow.current, res.data.Messages ?? []);\n setFeed(() => merged);\n setOlder(res.data.NextBefore ?? null);\n const cursor = lastCursor(id, merged);\n if (cursor) void client.chat.markRead(id, cursor);\n });\n return () => {\n alive = false;\n const cursor = lastCursor(id, feedNow.current);\n if (cursor) void client.chat.markRead(id, cursor);\n };\n }, [client, id]);\n\n // Keep the newest message in view.\n useEffect(() => {\n const el = feedRef.current;\n if (el) el.scrollTop = el.scrollHeight;\n }, [feed.length]);\n\n const loadOlder = async () => {\n if (!older) return;\n const res = await client.chat.getHistory(id, older);\n if (!res.ok) return;\n setFeed((prev) => mergeMessages(prev, res.data.Messages ?? []));\n setOlder(res.data.NextBefore ?? null);\n };\n\n const wait = slowModeLeft(lastSent, channel?.SlowModeSeconds, now);\n const message = sendable(text, maxLength);\n const canWrite = channel ? channel.CanWrite !== false : true;\n\n const send = async () => {\n if (!message || wait > 0) return;\n setBusy(true);\n const res = channel\n ? await client.chat.sendMessage(channel.ChannelID!, message)\n : await client.chat.sendDirect(id, message);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"tab\");\n setText(\"\");\n setLastSent(Date.now());\n // Render the SERVER's copy: moderation may have masked a word.\n if (res.data.Message)\n setFeed((prev) => mergeMessages(prev, [res.data.Message!]));\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gap: 8,\n height: \"min(70vh, 640px)\",\n minHeight: 320,\n }}\n >\n {onBack ? (\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Button size=\"sm\" tone=\"grey\" onClick={onBack}>\n ← {t(\"back\")}\n </Button>\n <span style={{ ...outlined, fontSize: 15 }}>\n {room.kind === \"direct\" ? room.peerName : \"\"}\n </span>\n </div>\n ) : (\n <div />\n )}\n <div\n ref={feedRef}\n className=\"idos-scroll\"\n style={{\n ...panel,\n padding: 10,\n overflowY: \"auto\",\n display: \"grid\",\n alignContent: \"start\",\n gap: 6,\n }}\n >\n {older ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => void loadOlder()}\n style={{ justifySelf: \"center\" }}\n >\n {t(\"older\")}\n </Button>\n ) : null}\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : feed.length === 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n padding: 20,\n }}\n >\n {t(\"noMessages\")}\n </div>\n ) : null}\n {feed.map((m) => {\n const mine = m.SenderUserID === me;\n const system = isSystem(m);\n return (\n <div\n key={m.MessageID}\n style={{\n display: \"flex\",\n justifyContent: system\n ? \"center\"\n : mine\n ? \"flex-end\"\n : \"flex-start\",\n }}\n >\n <div\n onClick={!mine && !system ? () => setPicked(m) : undefined}\n style={{\n maxWidth: \"80%\",\n padding: \"6px 10px\",\n borderRadius: 12,\n ...(system\n ? {\n background: `color-mix(in srgb, ${v.gold} 18%, transparent)`,\n }\n : mine\n ? toneSurface(\"blue\")\n : {\n background: `color-mix(in srgb, ${v.text} 10%, transparent)`,\n }),\n cursor: !mine && !system ? \"pointer\" : undefined,\n overflowWrap: \"anywhere\",\n }}\n >\n {!mine ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: system ? v.gold : v.textDim,\n }}\n >\n {system ? t(\"system\") : authorName(m)}\n </div>\n ) : null}\n <div style={{ fontSize: 14, fontWeight: 600 }}>{m.Text}</div>\n <div\n style={{\n fontSize: 10,\n color: v.textDim,\n textAlign: \"right\",\n }}\n >\n {m.SentAt\n ? new Date(m.SentAt).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"\"}\n </div>\n </div>\n </div>\n );\n })}\n </div>\n {canWrite ? (\n <div style={{ display: \"flex\", gap: 8 }}>\n <TextInput\n value={text}\n maxLength={maxLength}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n placeholder={t(\"placeholder\")}\n style={{ flex: 1, minWidth: 0, padding: \"10px 12px\" }}\n data-tutorial-anchor=\"chat:input\"\n />\n <Button\n tone=\"green\"\n disabled={!message || wait > 0}\n busy={busy}\n onClick={() => void send()}\n >\n {wait > 0 ? `${wait}${t(\"sec\")}` : <Icon glyph=\"chat\" size={20} />}\n </Button>\n </div>\n ) : (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {channel?.WriteBlockedReason ?? \"—\"}\n </div>\n )}\n {picked ? (\n <MessagePopup\n message={picked}\n roomID={id}\n directEnabled={directEnabled}\n onOpenDirect={onOpenDirect}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n"
64
+ },
65
+ {
66
+ "path": "helpers.ts",
67
+ "content": "import { type IDosGamesClient } from \"@idosgames/core\";\nimport { errorText } from \"@idosgames/react/ui\";\nimport { channelChoices, type ChannelView, type MessageView } from \"./model\";\n\nexport type Room =\n | { kind: \"channel\"; channel: ChannelView }\n | { kind: \"direct\"; roomID: string; peerName: string };\n\nexport type Conversation = {\n RoomID?: string | null;\n PeerUserID?: string | null;\n PeerProfile?: { Username?: string | null } | null;\n UnreadCount?: number | null;\n LastMessage?: MessageView | null;\n};\n\nexport const DIRECT = \"__direct\";\n\nexport type ChatDefs = {\n Direct?: { IsEnabled?: boolean | null } | null;\n Channels?: Parameters<typeof channelChoices>[0];\n};\n\nexport function roomID(room: Room | null): string {\n if (!room) return \"\";\n return room.kind === \"channel\" ? (room.channel.RoomID ?? \"\") : room.roomID;\n}\n\nexport async function openDirect(\n client: IDosGamesClient,\n userID: string,\n toast: (text: string, kind?: \"error\") => void,\n open: (r: Room) => void,\n): Promise<void> {\n const res = await client.chat.openDirectChannel(userID);\n if (!res.ok || !res.data.RoomID)\n return toast(errorText(res.ok ? \"\" : res.error), \"error\");\n open({\n kind: \"direct\",\n roomID: res.data.RoomID,\n peerName: res.data.PeerProfile?.Username ?? userID,\n });\n}\n"
48
68
  },
49
69
  {
50
70
  "path": "i18n.ts",
@@ -60,7 +80,7 @@
60
80
  },
61
81
  {
62
82
  "path": "module.ts",
63
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeChatScreen, makeChatWatcher } from \"./ChatScreen\";\nimport { t } from \"./i18n\";\n\n// Chat as a feature of the game. The watcher reads the unread count once per session — a closed\n// chat polls nothing (every request is billed to the publisher). features.open(\"chat\", { userID })\n// opens a private chat with that player.\nexport const chatModule: Module = defineModule({\n id: \"chat\",\n meta: { name: \"Chat\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"chat\",\n label: t(\"title\"),\n icon: \"chat\",\n group: \"social\",\n order: 85,\n available: false,\n Screen: makeChatScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeChatWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"chat\" }),\n describeActions: {},\n });\n },\n});\n"
83
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeChatScreen, watchChat } from \"./ChatScreen\";\nimport { t } from \"./i18n\";\n\n// Chat as a feature of the game. The watcher reads the unread count once per session — a closed\n// chat polls nothing (every request is billed to the publisher). features.open(\"chat\", { userID })\n// opens a private chat with that player.\nexport const chatModule: Module = defineModule({\n id: \"chat\",\n meta: { name: \"Chat\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"chat\",\n label: t(\"title\"),\n icon: \"chat\",\n group: \"social\",\n order: 85,\n available: false,\n Screen: makeChatScreen(ctx.features),\n });\n watchChat(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"chat\" }),\n describeActions: {},\n });\n },\n});\n"
64
84
  },
65
85
  {
66
86
  "path": "module.meta.json",
@@ -34,16 +34,32 @@
34
34
  "version": "0.1.1"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.21.1",
37
+ "@idosgames/core": "0.21.3",
38
38
  "@idosgames/module-sdk": "0.10.0",
39
- "@idosgames/react": "0.4.4",
39
+ "@idosgames/react": "0.4.5",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "04ecd67d6caa7dedcaa260db3f589667b291335c6dd5f5a57797204841c3396d",
42
+ "contentHash": "f95f32e4b2cefb62fdec637bd14fa5b4a1a6d71af1f7b76d6bae757725f90515",
43
43
  "files": [
44
44
  {
45
45
  "path": "CollectionsScreen.tsx",
46
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n CollectionTradeOfferDocument,\n FriendPublicProfile,\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 Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n albumSets,\n chestRows,\n collectionsBadge,\n currentCollection,\n grandPrizeState,\n packOptions,\n type CardRow,\n type ChestRow,\n type CollectionSection,\n type CollectionStateView,\n type PackOption,\n type SetRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Collections (engine Collection): an album of sets — every card the player holds or still misses,\n// a reward per completed set and a grand prize for the whole album; packs of cards (currencies) and\n// collection chests (the currency duplicates turn into); and trading a spare card with a friend.\n\ntype Tab = \"album\" | \"packs\" | \"trades\";\n\nexport function CollectionsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"album\");\n const section = configSection<CollectionSection>(client, \"Collection\");\n const mine = (state?.Collection ?? null) as CollectionStateView | null;\n const collection = currentCollection(section, mine);\n\n // The album is in the login state and every reply keeps it fresh; opening the screen re-checks it\n // through the central cache (a trade a friend accepted, the active collection changing).\n const reload = useCallback(async () => {\n await client.cache.ensureState([\"Collection\"], { maxAgeMs: 60_000 });\n }, [client]);\n useEffect(() => {\n void reload();\n }, [reload]);\n\n if (!collection) return <EmptyState glyph=\"star\" text={t(\"noCollection\")} />;\n const sets = albumSets(collection, mine);\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={[\n {\n id: \"album\",\n label: t(\"album\"),\n badge: collectionsBadge(collection, sets, mine),\n },\n { id: \"packs\", label: t(\"packs\") },\n { id: \"trades\", label: t(\"trades\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"album\" ? (\n <Album\n collectionID={collection.CollectionID}\n name={collection.DisplayName ?? collection.CollectionID}\n grandPrize={grantLines(collection.GrandPrize)}\n prize={grandPrizeState(collection, sets, mine)}\n sets={sets}\n onChanged={reload}\n />\n ) : tab === \"packs\" ? (\n <Packs\n collectionID={collection.CollectionID}\n section={section}\n balance={Number(mine?.CollectionCurrencyBalance ?? 0)}\n onChanged={reload}\n />\n ) : (\n <Trades collectionID={collection.CollectionID} sets={sets} />\n )}\n </div>\n );\n}\n\n// ── Album ────────────────────────────────────────────────────────────────────────────────────\n\nfunction Album({\n collectionID,\n name,\n grandPrize,\n prize,\n sets,\n onChanged,\n}: {\n collectionID: string;\n name: string;\n grandPrize: ReturnType<typeof grantLines>;\n prize: ReturnType<typeof grandPrizeState>;\n sets: SetRow[];\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const owned = sets.reduce((s, x) => s + x.owned, 0);\n const total = sets.reduce((s, x) => s + x.total, 0);\n\n const claimSet = async (setID: string) => {\n setBusy(setID);\n const res = await client.collection.claimSetReward(collectionID, setID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onChanged();\n };\n const claimPrize = async () => {\n setBusy(\"grand\");\n const res = await client.collection.claimGrandPrize(collectionID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n await onChanged();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(name)}\n </div>\n <ProgressBar value={owned} max={Math.max(1, total)} tone=\"gold\" />\n {grandPrize.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList lines={grandPrize} size={20} />\n {prize === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claimPrize()}\n >\n {t(\"claim\")}\n </Button>\n ) : prize === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n ) : null}\n </div>\n {sets.map((s, i) => (\n <Card key={s.setID} highlight={s.state === \"ready\"} style={stagger(i)}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(s.name)}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {s.owned} / {s.total}\n </span>\n </div>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {s.cards.map((c) => (\n <CardTile key={c.id} card={c} />\n ))}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <ResourceList lines={s.reward} size={18} />\n {s.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === s.setID}\n onClick={() => void claimSet(s.setID)}\n data-tutorial-anchor={`collection:set:${s.setID}`}\n >\n {t(\"claim\")}\n </Button>\n ) : s.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={20} />\n ) : null}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction CardTile({ card }: { card: CardRow }): ReactNode {\n const catalog = useCatalog();\n const has = card.count > 0;\n return (\n <div\n title={catalog.localize(card.name)}\n style={{\n position: \"relative\",\n aspectRatio: \"3 / 4\",\n borderRadius: 10,\n display: \"grid\",\n placeItems: \"center\",\n background: has ? v.panelDeep : v.well,\n border: `2px solid ${has ? catalog.rarityColor(String(card.rarity)) : v.line}`,\n opacity: has ? 1 : 0.45,\n }}\n >\n <Icon glyph={has ? card.icon : \"lock\"} size={30} />\n {card.count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n right: 4,\n bottom: 2,\n ...outlined,\n fontSize: 12,\n }}\n >\n ×{card.count}\n </span>\n ) : null}\n </div>\n );\n}\n\n// ── Packs and chests ─────────────────────────────────────────────────────────────────────────\n\nfunction Packs({\n collectionID,\n section,\n balance,\n onChanged,\n}: {\n collectionID: string;\n section: CollectionSection | undefined;\n balance: number;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const state = useUserState();\n const stagger = useStagger();\n const [opening, setOpening] = useState<\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow }\n | null\n >(null);\n const packs = packOptions(section);\n const chests = chestRows(section);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n if (packs.length === 0 && chests.length === 0)\n return <EmptyState glyph=\"ticket\" text={t(\"noPacks\")} />;\n const bulkOf = (p: PackOption) =>\n p.maxOpen >= 10 &&\n canAfford(\n p.price.map((l) => ({ ...l, amount: l.amount * 10 })),\n balances,\n );\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {packs.length > 0 ? (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {packs.map((p, i) => {\n const affordable = canAfford(p.price, balances);\n return (\n <Card\n key={`${p.packTypeID}:${p.optionID ?? \"\"}`}\n style={{\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {p.name}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={p.icon} size={56} />\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {p.cards} {t(\"cards\")}\n </div>\n <Button\n tone={p.price.length === 0 ? \"green\" : \"gold\"}\n disabled={!affordable}\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 1 })\n }\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`collection:pack:${p.packTypeID}`}\n >\n {p.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={p.price} size={18} />\n )}\n </Button>\n {bulkOf(p) ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 10 })\n }\n style={{ width: \"100%\" }}\n >\n {t(\"open\")} ×10\n </Button>\n ) : null}\n </Card>\n );\n })}\n </div>\n ) : null}\n {chests.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle\n right={\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"points\")}: {balance}\n </span>\n }\n >\n {t(\"chests\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {chests.map((c) => (\n <Card\n key={c.chestID}\n style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {c.name}\n </div>\n <Icon glyph={c.icon} size={56} />\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {c.cards} {t(\"cards\")}\n </div>\n <Button\n tone=\"gold\"\n disabled={balance < c.cost}\n onClick={() => setOpening({ kind: \"chest\", chest: c })}\n style={{ width: \"100%\" }}\n >\n {c.cost} {t(\"points\")}\n </Button>\n </Card>\n ))}\n </div>\n </div>\n ) : null}\n {opening ? (\n <OpenPopup\n collectionID={collectionID}\n opening={opening}\n onClose={() => setOpening(null)}\n onOpened={onChanged}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OpenPopup({\n collectionID,\n opening,\n onClose,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onClose: () => void;\n onOpened: () => Promise<void>;\n}): ReactNode {\n const name = opening.kind === \"pack\" ? opening.pack.name : opening.chest.name;\n const count = opening.kind === \"pack\" ? opening.count : 1;\n return (\n <Popup\n title={count > 1 ? `${name} ×${count}` : name}\n onClose={onClose}\n variant=\"center\"\n >\n <OpenBody\n collectionID={collectionID}\n opening={opening}\n onOpened={onOpened}\n />\n </Popup>\n );\n}\n\nfunction OpenBody({\n collectionID,\n opening,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onOpened: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const [got, setGot] = useState<Array<{ id: string; dup: boolean }> | null>(\n null,\n );\n\n const open = async () => {\n setBusy(true);\n const started = Date.now();\n const res =\n opening.kind === \"pack\"\n ? await client.collection.openPack(\n collectionID,\n opening.pack.packTypeID,\n opening.count,\n opening.pack.optionID\n ? { selectedOptionID: opening.pack.optionID }\n : undefined,\n )\n : await client.collection.openCollectionChest(\n collectionID,\n opening.chest.chestID,\n );\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 700 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setGot([\n ...(res.data.GrantedCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: false,\n })),\n ...(res.data.DuplicateCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: true,\n })),\n ]);\n const bonus = grantedBy(res.data);\n if (bonus.length > 0) celebrate(bonus);\n await onOpened();\n };\n\n if (got)\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(72px, 1fr))\",\n gap: 8,\n }}\n >\n {got.map((c, i) => (\n <div\n key={`${c.id}:${i}`}\n style={{\n ...panel,\n padding: 6,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n opacity: c.dup ? 0.6 : 1,\n }}\n >\n <Icon glyph=\"star\" size={28} />\n <span style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\n {catalog.localize(c.id)}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 10,\n color: c.dup ? v.textDim : v.green,\n }}\n >\n {c.dup ? t(\"duplicate\") : t(\"new\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={close}>\n {t(\"great\")}\n </Button>\n </div>\n );\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon\n glyph={\n opening.kind === \"pack\" ? opening.pack.icon : opening.chest.icon\n }\n size={110}\n />\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void open()}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n </div>\n );\n}\n\n// ── Trades ───────────────────────────────────────────────────────────────────────────────────\n\nfunction Trades({\n collectionID,\n sets,\n}: {\n collectionID: string;\n sets: SetRow[];\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [incoming, setIncoming] = useState<\n CollectionTradeOfferDocument[] | null\n >(null);\n const [mine, setMine] = useState<CollectionTradeOfferDocument[]>([]);\n const [giving, setGiving] = useState<CardRow | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const spare = sets.flatMap((s) => s.cards).filter((c) => c.spare > 0);\n\n const load = useCallback(async () => {\n const [inc, my] = await Promise.all([\n client.collection.getIncomingTradeOffers(collectionID),\n client.collection.getMyTradeOffers(collectionID),\n ]);\n setIncoming(inc.ok ? (inc.data.Offers ?? []) : []);\n setMine(my.ok ? (my.data.Offers ?? []) : []);\n }, [client, collectionID]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const act = async (\n offerID: string,\n action: \"accept\" | \"decline\" | \"cancel\",\n ) => {\n setBusy(offerID);\n const res =\n action === \"accept\"\n ? await client.collection.acceptTradeOffer(offerID)\n : action === \"decline\"\n ? await client.collection.declineTradeOffer(offerID)\n : await client.collection.cancelTradeOffer(offerID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (action === \"accept\") {\n toast(t(\"traded\"), \"success\");\n void client.collection.getUserState();\n }\n await load();\n };\n\n if (!incoming)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const pending = (o: CollectionTradeOfferDocument) =>\n !o.Status || o.Status === \"Pending\";\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"incoming\")}</SectionTitle>\n {incoming.filter(pending).length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noIncoming\")}\n </div>\n ) : (\n incoming.filter(pending).map((o) => (\n <Card key={o.OfferID} style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.SenderPublicData?.Username ?? t(\"player\")} ·{\" \"}\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n {o.RequestedCollectibleID\n ? ` ⇄ ${catalog.localize(o.RequestedCollectibleID)}`\n : \"\"}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"accept\")}\n >\n {t(\"accept\")}\n </Button>\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(o.OfferID, \"decline\")}\n >\n {t(\"decline\")}\n </Button>\n </div>\n </Card>\n ))\n )}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"sent\")}</SectionTitle>\n {mine.filter(pending).map((o) => (\n <Card\n key={o.OfferID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n </span>\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"cancel\")}\n >\n {t(\"cancel\")}\n </Button>\n </Card>\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"giveSpare\")}</SectionTitle>\n {spare.length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noSpare\")}\n </div>\n ) : (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {spare.map((c) => (\n <button\n key={c.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setGiving(c)}\n style={{ border: \"none\", background: \"none\", padding: 0 }}\n >\n <CardTile card={c} />\n </button>\n ))}\n </div>\n )}\n </div>\n {giving ? (\n <GivePopup\n collectionID={collectionID}\n card={giving}\n onClose={() => setGiving(null)}\n onSent={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction GivePopup({\n collectionID,\n card,\n onClose,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onClose: () => void;\n onSent: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup\n title={`${t(\"offerTo\")}: ${catalog.localize(card.name)}`}\n onClose={onClose}\n width={420}\n >\n <FriendPicker collectionID={collectionID} card={card} onSent={onSent} />\n </Popup>\n );\n}\n\nfunction FriendPicker({\n collectionID,\n card,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [friends, setFriends] = useState<FriendPublicProfile[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.social\n .getFriendsList()\n .then((r) => setFriends(r.ok ? (r.data.Friends ?? []) : []));\n }, [client]);\n if (!friends)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (friends.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n const send = async (userID: string) => {\n setBusy(userID);\n const res = await client.collection.sendTradeOffer(\n collectionID,\n card.id,\n false,\n userID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"offerSent\"), \"success\");\n close();\n await onSent();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {friends.map((f) => (\n <Card\n key={f.UserID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {f.PublicData?.Username ?? t(\"player\")}\n </span>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === f.UserID}\n onClick={() => void send(f.UserID)}\n >\n {t(\"give\")}\n </Button>\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/** Badge: set rewards and the grand prize waiting — only on a title with a collection. */\nexport function makeCollectionsWatcher(features: FeatureRegistry) {\n return function CollectionsBadge(): 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 reward is taken.\n const update = () => {\n const mine = (client.data.user.state?.Collection ??\n null) as CollectionStateView | null;\n const collection = currentCollection(\n configSection<CollectionSection>(client, \"Collection\"),\n mine,\n );\n features.setBadge(\n \"collections\",\n collectionsBadge(collection, albumSets(collection, mine), mine),\n );\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n // From the login state; the server only when the active collection may have changed.\n void client.cache.ensureState([\"Collection\"]);\n return off;\n }, [client]);\n return null;\n };\n}\n"
46
+ "content": "import {\n type ModuleContext,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n EmptyState,\n Tabs,\n configSection,\n grantLines,\n} from \"@idosgames/react/ui\";\nimport {\n albumSets,\n collectionsBadge,\n currentCollection,\n grandPrizeState,\n type CollectionStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\nimport { Album } from \"./components/Album\";\nimport { Packs } from \"./components/Packs\";\nimport { Trades } from \"./components/Trades\";\n\n// Collections (engine Collection): an album of sets — every card the player holds or still misses,\n// a reward per completed set and a grand prize for the whole album; packs of cards (currencies) and\n// collection chests (the currency duplicates turn into); and trading a spare card with a friend.\n\ntype Tab = \"album\" | \"packs\" | \"trades\";\n\nexport function CollectionsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"album\");\n const section = configSection(client, \"Collection\");\n const mine = (state?.Collection ?? null) as CollectionStateView | null;\n const collection = currentCollection(section, mine);\n\n // The album is in the login state and every reply keeps it fresh; opening the screen re-checks it\n // through the central cache (a trade a friend accepted, the active collection changing).\n const reload = useCallback(async () => {\n await client.cache.ensureState([\"Collection\"], { maxAgeMs: 60_000 });\n }, [client]);\n useEffect(() => {\n void reload();\n }, [reload]);\n\n if (!collection) return <EmptyState glyph=\"star\" text={t(\"noCollection\")} />;\n const sets = albumSets(collection, mine);\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={[\n {\n id: \"album\",\n label: t(\"album\"),\n badge: collectionsBadge(collection, sets, mine),\n },\n { id: \"packs\", label: t(\"packs\") },\n { id: \"trades\", label: t(\"trades\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"album\" ? (\n <Album\n collectionID={collection.CollectionID}\n name={collection.DisplayName ?? collection.CollectionID}\n grandPrize={grantLines(collection.GrandPrize)}\n prize={grandPrizeState(collection, sets, mine)}\n sets={sets}\n onChanged={reload}\n />\n ) : tab === \"packs\" ? (\n <Packs\n collectionID={collection.CollectionID}\n section={section}\n balance={Number(mine?.CollectionCurrencyBalance ?? 0)}\n onChanged={reload}\n />\n ) : (\n <Trades collectionID={collection.CollectionID} sets={sets} />\n )}\n </div>\n );\n}\n\n// ── Album ────────────────────────────────────────────────────────────────────────────────────\n\n/** Badge: set rewards and the grand prize waiting — only on a title with a collection. */\nexport function watchCollections(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\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 reward is taken.\n const update = () => {\n const mine = (client.data.user.state?.Collection ??\n null) as CollectionStateView | null;\n const collection = currentCollection(\n configSection(client, \"Collection\"),\n mine,\n );\n features.setBadge(\n \"collections\",\n collectionsBadge(collection, albumSets(collection, mine), mine),\n );\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n // From the login state; the server only when the active collection may have changed.\n void client.cache.ensureState([\"Collection\"]);\n return off;\n });\n}\n"
47
+ },
48
+ {
49
+ "path": "components/Album.tsx",
50
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Icon,\n ProgressBar,\n ResourceList,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { grandPrizeState, type SetRow } from \"../model\";\nimport { t } from \"../i18n\";\nimport { CardTile } from \"./CardTile\";\n\nexport function Album({\n collectionID,\n name,\n grandPrize,\n prize,\n sets,\n onChanged,\n}: {\n collectionID: string;\n name: string;\n grandPrize: ReturnType<typeof grantLines>;\n prize: ReturnType<typeof grandPrizeState>;\n sets: SetRow[];\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const owned = sets.reduce((s, x) => s + x.owned, 0);\n const total = sets.reduce((s, x) => s + x.total, 0);\n\n const claimSet = async (setID: string) => {\n setBusy(setID);\n const res = await client.collection.claimSetReward(collectionID, setID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onChanged();\n };\n const claimPrize = async () => {\n setBusy(\"grand\");\n const res = await client.collection.claimGrandPrize(collectionID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n await onChanged();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(name)}\n </div>\n <ProgressBar value={owned} max={Math.max(1, total)} tone=\"gold\" />\n {grandPrize.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList lines={grandPrize} size={20} />\n {prize === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claimPrize()}\n >\n {t(\"claim\")}\n </Button>\n ) : prize === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n ) : null}\n </div>\n {sets.map((s, i) => (\n <Card key={s.setID} highlight={s.state === \"ready\"} style={stagger(i)}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(s.name)}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {s.owned} / {s.total}\n </span>\n </div>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {s.cards.map((c) => (\n <CardTile key={c.id} card={c} />\n ))}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <ResourceList lines={s.reward} size={18} />\n {s.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === s.setID}\n onClick={() => void claimSet(s.setID)}\n data-tutorial-anchor={`collection:set:${s.setID}`}\n >\n {t(\"claim\")}\n </Button>\n ) : s.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={20} />\n ) : null}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n"
51
+ },
52
+ {
53
+ "path": "components/CardTile.tsx",
54
+ "content": "import { type ReactNode } from \"react\";\nimport { Icon, outlined, useCatalog, v } from \"@idosgames/react/ui\";\nimport { type CardRow } from \"../model\";\n\nexport function CardTile({ card }: { card: CardRow }): ReactNode {\n const catalog = useCatalog();\n const has = card.count > 0;\n return (\n <div\n title={catalog.localize(card.name)}\n style={{\n position: \"relative\",\n aspectRatio: \"3 / 4\",\n borderRadius: 10,\n display: \"grid\",\n placeItems: \"center\",\n background: has ? v.panelDeep : v.well,\n border: `2px solid ${has ? catalog.rarityColor(String(card.rarity)) : v.line}`,\n opacity: has ? 1 : 0.45,\n }}\n >\n <Icon glyph={has ? card.icon : \"lock\"} size={30} />\n {card.count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n right: 4,\n bottom: 2,\n ...outlined,\n fontSize: 12,\n }}\n >\n ×{card.count}\n </span>\n ) : null}\n </div>\n );\n}\n\n// ── Packs and chests ─────────────────────────────────────────────────────────────────────────\n"
55
+ },
56
+ {
57
+ "path": "components/Packs.tsx",
58
+ "content": "import { useRef, useState, type ReactNode } from \"react\";\nimport { type CollectionDefinitions } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n canAfford,\n errorText,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n chestRows,\n packOptions,\n type ChestRow,\n type PackOption,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function Packs({\n collectionID,\n section,\n balance,\n onChanged,\n}: {\n collectionID: string;\n section: CollectionDefinitions | undefined;\n balance: number;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const state = useUserState();\n const stagger = useStagger();\n const [opening, setOpening] = useState<\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow }\n | null\n >(null);\n const packs = packOptions(section);\n const chests = chestRows(section);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n if (packs.length === 0 && chests.length === 0)\n return <EmptyState glyph=\"ticket\" text={t(\"noPacks\")} />;\n const bulkOf = (p: PackOption) =>\n p.maxOpen >= 10 &&\n canAfford(\n p.price.map((l) => ({ ...l, amount: l.amount * 10 })),\n balances,\n );\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {packs.length > 0 ? (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {packs.map((p, i) => {\n const affordable = canAfford(p.price, balances);\n return (\n <Card\n key={`${p.packTypeID}:${p.optionID ?? \"\"}`}\n style={{\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {p.name}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={p.icon} size={56} />\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {p.cards} {t(\"cards\")}\n </div>\n <Button\n tone={p.price.length === 0 ? \"green\" : \"gold\"}\n disabled={!affordable}\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 1 })\n }\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`collection:pack:${p.packTypeID}`}\n >\n {p.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={p.price} size={18} />\n )}\n </Button>\n {bulkOf(p) ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 10 })\n }\n style={{ width: \"100%\" }}\n >\n {t(\"open\")} ×10\n </Button>\n ) : null}\n </Card>\n );\n })}\n </div>\n ) : null}\n {chests.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle\n right={\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"points\")}: {balance}\n </span>\n }\n >\n {t(\"chests\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {chests.map((c) => (\n <Card\n key={c.chestID}\n style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {c.name}\n </div>\n <Icon glyph={c.icon} size={56} />\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {c.cards} {t(\"cards\")}\n </div>\n <Button\n tone=\"gold\"\n disabled={balance < c.cost}\n onClick={() => setOpening({ kind: \"chest\", chest: c })}\n style={{ width: \"100%\" }}\n >\n {c.cost} {t(\"points\")}\n </Button>\n </Card>\n ))}\n </div>\n </div>\n ) : null}\n {opening ? (\n <OpenPopup\n collectionID={collectionID}\n opening={opening}\n onClose={() => setOpening(null)}\n onOpened={onChanged}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OpenPopup({\n collectionID,\n opening,\n onClose,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onClose: () => void;\n onOpened: () => Promise<void>;\n}): ReactNode {\n const name = opening.kind === \"pack\" ? opening.pack.name : opening.chest.name;\n const count = opening.kind === \"pack\" ? opening.count : 1;\n return (\n <Popup\n title={count > 1 ? `${name} ×${count}` : name}\n onClose={onClose}\n variant=\"center\"\n >\n <OpenBody\n collectionID={collectionID}\n opening={opening}\n onOpened={onOpened}\n />\n </Popup>\n );\n}\n\nfunction OpenBody({\n collectionID,\n opening,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onOpened: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const [got, setGot] = useState<Array<{ id: string; dup: boolean }> | null>(\n null,\n );\n\n const open = async () => {\n setBusy(true);\n const started = Date.now();\n const res =\n opening.kind === \"pack\"\n ? await client.collection.openPack(\n collectionID,\n opening.pack.packTypeID,\n opening.count,\n opening.pack.optionID\n ? { selectedOptionID: opening.pack.optionID }\n : undefined,\n )\n : await client.collection.openCollectionChest(\n collectionID,\n opening.chest.chestID,\n );\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 700 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setGot([\n ...(res.data.GrantedCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: false,\n })),\n ...(res.data.DuplicateCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: true,\n })),\n ]);\n const bonus = grantedBy(res.data);\n if (bonus.length > 0) celebrate(bonus);\n await onOpened();\n };\n\n if (got)\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(72px, 1fr))\",\n gap: 8,\n }}\n >\n {got.map((c, i) => (\n <div\n key={`${c.id}:${i}`}\n style={{\n ...panel,\n padding: 6,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n opacity: c.dup ? 0.6 : 1,\n }}\n >\n <Icon glyph=\"star\" size={28} />\n <span style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\n {catalog.localize(c.id)}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 10,\n color: c.dup ? v.textDim : v.green,\n }}\n >\n {c.dup ? t(\"duplicate\") : t(\"new\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={close}>\n {t(\"great\")}\n </Button>\n </div>\n );\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon\n glyph={\n opening.kind === \"pack\" ? opening.pack.icon : opening.chest.icon\n }\n size={110}\n />\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void open()}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n </div>\n );\n}\n\n// ── Trades ───────────────────────────────────────────────────────────────────────────────────\n"
59
+ },
60
+ {
61
+ "path": "components/Trades.tsx",
62
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type CollectionTradeOfferDocument,\n type FriendPublicProfile,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Popup,\n SectionTitle,\n Spinner,\n errorText,\n outlined,\n useCatalog,\n usePopupClose,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { type CardRow, type SetRow } from \"../model\";\nimport { t } from \"../i18n\";\nimport { CardTile } from \"./CardTile\";\n\nexport function Trades({\n collectionID,\n sets,\n}: {\n collectionID: string;\n sets: SetRow[];\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [incoming, setIncoming] = useState<\n CollectionTradeOfferDocument[] | null\n >(null);\n const [mine, setMine] = useState<CollectionTradeOfferDocument[]>([]);\n const [giving, setGiving] = useState<CardRow | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const spare = sets.flatMap((s) => s.cards).filter((c) => c.spare > 0);\n\n const load = useCallback(async () => {\n const [inc, my] = await Promise.all([\n client.collection.getIncomingTradeOffers(collectionID),\n client.collection.getMyTradeOffers(collectionID),\n ]);\n setIncoming(inc.ok ? (inc.data.Offers ?? []) : []);\n setMine(my.ok ? (my.data.Offers ?? []) : []);\n }, [client, collectionID]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const act = async (\n offerID: string,\n action: \"accept\" | \"decline\" | \"cancel\",\n ) => {\n setBusy(offerID);\n const res =\n action === \"accept\"\n ? await client.collection.acceptTradeOffer(offerID)\n : action === \"decline\"\n ? await client.collection.declineTradeOffer(offerID)\n : await client.collection.cancelTradeOffer(offerID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (action === \"accept\") {\n toast(t(\"traded\"), \"success\");\n // The trade marked `Collection` for a re-read — the album picks up the new card by revision.\n void client.cache.ensureState([\"Collection\"]);\n }\n await load();\n };\n\n if (!incoming)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const pending = (o: CollectionTradeOfferDocument) =>\n !o.Status || o.Status === \"Pending\";\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"incoming\")}</SectionTitle>\n {incoming.filter(pending).length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noIncoming\")}\n </div>\n ) : (\n incoming.filter(pending).map((o) => (\n <Card key={o.OfferID} style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.SenderPublicData?.Username ?? t(\"player\")} ·{\" \"}\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n {o.RequestedCollectibleID\n ? ` ⇄ ${catalog.localize(o.RequestedCollectibleID)}`\n : \"\"}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"accept\")}\n >\n {t(\"accept\")}\n </Button>\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(o.OfferID, \"decline\")}\n >\n {t(\"decline\")}\n </Button>\n </div>\n </Card>\n ))\n )}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"sent\")}</SectionTitle>\n {mine.filter(pending).map((o) => (\n <Card\n key={o.OfferID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n </span>\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"cancel\")}\n >\n {t(\"cancel\")}\n </Button>\n </Card>\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"giveSpare\")}</SectionTitle>\n {spare.length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noSpare\")}\n </div>\n ) : (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {spare.map((c) => (\n <button\n key={c.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setGiving(c)}\n style={{ border: \"none\", background: \"none\", padding: 0 }}\n >\n <CardTile card={c} />\n </button>\n ))}\n </div>\n )}\n </div>\n {giving ? (\n <GivePopup\n collectionID={collectionID}\n card={giving}\n onClose={() => setGiving(null)}\n onSent={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction GivePopup({\n collectionID,\n card,\n onClose,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onClose: () => void;\n onSent: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup\n title={`${t(\"offerTo\")}: ${catalog.localize(card.name)}`}\n onClose={onClose}\n width={420}\n >\n <FriendPicker collectionID={collectionID} card={card} onSent={onSent} />\n </Popup>\n );\n}\n\nfunction FriendPicker({\n collectionID,\n card,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [friends, setFriends] = useState<FriendPublicProfile[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.social\n .getFriendsList()\n .then((r) => setFriends(r.ok ? (r.data.Friends ?? []) : []));\n }, [client]);\n if (!friends)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (friends.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n const send = async (userID: string) => {\n setBusy(userID);\n const res = await client.collection.sendTradeOffer(\n collectionID,\n card.id,\n false,\n userID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"offerSent\"), \"success\");\n close();\n await onSent();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {friends.map((f) => (\n <Card\n key={f.UserID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {f.PublicData?.Username ?? t(\"player\")}\n </span>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === f.UserID}\n onClick={() => void send(f.UserID)}\n >\n {t(\"give\")}\n </Button>\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n"
47
63
  },
48
64
  {
49
65
  "path": "i18n.ts",
@@ -55,11 +71,11 @@
55
71
  },
56
72
  {
57
73
  "path": "model.ts",
58
- "content": "// Pure helpers of the collections screen — covered by model.test.ts. The server rolls packs and\n// decides what is complete; these only decide what the screen draws.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface CollectibleView {\n CollectibleID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n Rarity?: number | null;\n SortOrder?: number | null;\n}\n\nexport interface SetView {\n SetID?: string | null;\n DisplayName?: string | null;\n SortOrder?: number | null;\n Collectibles?: CollectibleView[] | null;\n SetCompletionReward?: ResourceGrant | null;\n}\n\nexport interface CollectionView {\n CollectionID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Sets?: SetView[] | null;\n GrandPrize?: ResourceGrant | null;\n}\n\nexport interface PackTypeView {\n PackTypeID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n CollectibleCount?: number | null;\n MaxOpenCount?: number | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n}\n\nexport interface CollectionChestView {\n CollectionChestID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n CollectionCurrencyCost?: number | null;\n MinCollectibleCount?: number | null;\n MaxCollectibleCount?: number | null;\n}\n\n/** The title's Collection section, as far as the screen cares. */\nexport interface CollectionSection {\n Collections?: Record<string, CollectionView | null> | null;\n PackTypes?: Record<string, PackTypeView | null> | null;\n CollectionChests?: Array<CollectionChestView | null> | null;\n Settings?: { MaxPackOpenCount?: number | null } | null;\n}\n\n/** The player's collection state (UserState.Collection). */\nexport interface CollectionStateView {\n CollectionID?: string;\n CollectionCurrencyBalance?: number;\n OwnedCollectibles?: Record<string, number>;\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[];\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n}\n\n// ── Album ────────────────────────────────────────────────────────────────────────────────────\n\n/** The collection the player collects: the one in their state, else the title's first. */\nexport function currentCollection(\n section: CollectionSection | null | undefined,\n state: CollectionStateView | null | undefined,\n): (CollectionView & { CollectionID: string }) | null {\n const all = Object.entries(section?.Collections ?? {})\n .filter(([, c]) => c)\n .map(([id, c]) => ({ ...c!, CollectionID: c!.CollectionID || id }))\n .sort((a, b) => a.CollectionID.localeCompare(b.CollectionID));\n return (\n all.find((c) => c.CollectionID === state?.CollectionID) ?? all[0] ?? null\n );\n}\n\nexport interface CardRow {\n id: string;\n name: string;\n icon: string;\n rarity: number;\n /** How many the player holds (0 — a gap in the album). */\n count: number;\n /** Duplicates the player can give away in a trade. */\n spare: number;\n}\n\nexport interface SetRow {\n setID: string;\n name: string;\n cards: CardRow[];\n owned: number;\n total: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"collecting\";\n}\n\n/** The album: sets in order, their cards with what the player holds, and each set's reward state. */\nexport function albumSets(\n collection: CollectionView | null | undefined,\n state: CollectionStateView | null | undefined,\n): SetRow[] {\n const owned = state?.OwnedCollectibles ?? {};\n const claimed = state?.ClaimedSetRewards ?? [];\n return [...(collection?.Sets ?? [])]\n .filter((s): s is SetView => !!s)\n .sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n (a.SetID ?? \"\").localeCompare(b.SetID ?? \"\"),\n )\n .map((s) => {\n const setID = s.SetID ?? \"\";\n const cards = [...(s.Collectibles ?? [])]\n .filter((c): c is CollectibleView => !!c?.CollectibleID)\n .sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n a.CollectibleID!.localeCompare(b.CollectibleID!),\n )\n .map((c) => {\n const count = Number(owned[c.CollectibleID!] ?? 0);\n return {\n id: c.CollectibleID!,\n name: c.DisplayName || c.CollectibleID!,\n icon: c.AssetPaths?.icon ?? \"star\",\n rarity: Number(c.Rarity ?? 1),\n count,\n spare: Math.max(0, count - 1),\n };\n });\n const have = cards.filter((c) => c.count > 0).length;\n return {\n setID,\n name: s.DisplayName || setID,\n cards,\n owned: have,\n total: cards.length,\n reward: grantLines(s.SetCompletionReward),\n state: claimed.includes(setID)\n ? \"claimed\"\n : cards.length > 0 && have === cards.length\n ? \"ready\"\n : \"collecting\",\n };\n });\n}\n\n/** The grand prize: claimable once every set is complete (\"none\" — the collection has no prize). */\nexport function grandPrizeState(\n collection: CollectionView | null | undefined,\n sets: SetRow[],\n state: CollectionStateView | null | undefined,\n): \"none\" | \"claimed\" | \"ready\" | \"locked\" {\n if (grantLines(collection?.GrandPrize).length === 0) return \"none\";\n if (state?.GrandPrizeClaimed) return \"claimed\";\n return sets.length > 0 && sets.every((s) => s.state !== \"collecting\")\n ? \"ready\"\n : \"locked\";\n}\n\n// ── Packs and chests ─────────────────────────────────────────────────────────────────────────\n\nexport interface PackOption {\n packTypeID: string;\n optionID: string | null;\n name: string;\n icon: string;\n cards: number;\n price: ResourceLine[];\n maxOpen: number;\n}\n\n/** Every way to open every pack type (one entry per price option; free packs have one entry). */\nexport function packOptions(\n section: CollectionSection | null | undefined,\n): PackOption[] {\n const out: PackOption[] = [];\n for (const [id, p] of Object.entries(section?.PackTypes ?? {})) {\n if (!p) continue;\n const maxOpen = Number(\n p.MaxOpenCount ?? section?.Settings?.MaxPackOpenCount ?? 100,\n );\n if (maxOpen <= 0) continue;\n const base = {\n packTypeID: p.PackTypeID || id,\n name: p.DisplayName || p.PackTypeID || id,\n icon: p.AssetPaths?.icon ?? \"ticket\",\n cards: Number(p.CollectibleCount ?? 1),\n maxOpen,\n };\n const options = Object.entries(p.PriceOptions ?? {}).filter(([, o]) => o);\n if (options.length === 0) out.push({ ...base, optionID: null, price: [] });\n for (const [oid, o] of options)\n out.push({\n ...base,\n optionID: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n });\n }\n return out.sort(\n (a, b) =>\n a.packTypeID.localeCompare(b.packTypeID) ||\n (a.optionID ?? \"\").localeCompare(b.optionID ?? \"\"),\n );\n}\n\nexport interface ChestRow {\n chestID: string;\n name: string;\n icon: string;\n cost: number;\n cards: string;\n}\n\n/** Collection chests: bought with the collection's own currency (what duplicates turn into). */\nexport function chestRows(\n section: CollectionSection | null | undefined,\n): ChestRow[] {\n return (section?.CollectionChests ?? [])\n .filter((c): c is CollectionChestView => !!c?.CollectionChestID)\n .map((c) => {\n const min = Number(c.MinCollectibleCount ?? 1);\n const max = Number(c.MaxCollectibleCount ?? min);\n return {\n chestID: c.CollectionChestID!,\n name: c.DisplayName || c.CollectionChestID!,\n icon: c.AssetPaths?.icon ?? \"chest\",\n cost: Number(c.CollectionCurrencyCost ?? 0),\n cards: max > min ? `${min}–${max}` : `${min}`,\n };\n });\n}\n\n/** Badge: set rewards and the grand prize waiting. */\nexport function collectionsBadge(\n collection: CollectionView | null | undefined,\n sets: SetRow[],\n state: CollectionStateView | null | undefined,\n): number {\n return (\n sets.filter((s) => s.state === \"ready\").length +\n (grandPrizeState(collection, sets, state) === \"ready\" ? 1 : 0)\n );\n}\n\n/** Whether the title has a collection — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: CollectionSection | undefined): boolean {\n return Object.values(section?.Collections ?? {}).some(Boolean);\n}\n"
74
+ "content": "// Pure helpers of the collections screen — covered by model.test.ts. The server rolls packs and\n// decides what is complete; these only decide what the screen draws.\n\nimport type { ResourceGrant, CollectionDefinitions } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface CollectibleView {\n CollectibleID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n Rarity?: number | null;\n SortOrder?: number | null;\n}\n\nexport interface SetView {\n SetID?: string | null;\n DisplayName?: string | null;\n SortOrder?: number | null;\n Collectibles?: CollectibleView[] | null;\n SetCompletionReward?: ResourceGrant | null;\n}\n\nexport interface CollectionView {\n CollectionID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Sets?: SetView[] | null;\n GrandPrize?: ResourceGrant | null;\n}\n\nexport interface PackTypeView {\n PackTypeID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n CollectibleCount?: number | null;\n MaxOpenCount?: number | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n}\n\nexport interface CollectionChestView {\n CollectionChestID?: string | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n CollectionCurrencyCost?: number | null;\n MinCollectibleCount?: number | null;\n MaxCollectibleCount?: number | null;\n}\n\n/** The player's collection state (UserState.Collection). */\nexport interface CollectionStateView {\n CollectionID?: string;\n CollectionCurrencyBalance?: number;\n OwnedCollectibles?: Record<string, number>;\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[];\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n}\n\n// ── Album ────────────────────────────────────────────────────────────────────────────────────\n\n/** The collection the player collects: the one in their state, else the title's first. */\nexport function currentCollection(\n section: CollectionDefinitions | null | undefined,\n state: CollectionStateView | null | undefined,\n): (CollectionView & { CollectionID: string }) | null {\n const all = Object.entries(section?.Collections ?? {})\n .filter(([, c]) => c)\n .map(([id, c]) => ({ ...c!, CollectionID: c!.CollectionID || id }))\n .sort((a, b) => a.CollectionID.localeCompare(b.CollectionID));\n return (\n all.find((c) => c.CollectionID === state?.CollectionID) ?? all[0] ?? null\n );\n}\n\nexport interface CardRow {\n id: string;\n name: string;\n icon: string;\n rarity: number;\n /** How many the player holds (0 — a gap in the album). */\n count: number;\n /** Duplicates the player can give away in a trade. */\n spare: number;\n}\n\nexport interface SetRow {\n setID: string;\n name: string;\n cards: CardRow[];\n owned: number;\n total: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"collecting\";\n}\n\n/** The album: sets in order, their cards with what the player holds, and each set's reward state. */\nexport function albumSets(\n collection: CollectionView | null | undefined,\n state: CollectionStateView | null | undefined,\n): SetRow[] {\n const owned = state?.OwnedCollectibles ?? {};\n const claimed = state?.ClaimedSetRewards ?? [];\n return [...(collection?.Sets ?? [])]\n .filter((s): s is SetView => !!s)\n .sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n (a.SetID ?? \"\").localeCompare(b.SetID ?? \"\"),\n )\n .map((s) => {\n const setID = s.SetID ?? \"\";\n const cards = [...(s.Collectibles ?? [])]\n .filter((c): c is CollectibleView => !!c?.CollectibleID)\n .sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n a.CollectibleID!.localeCompare(b.CollectibleID!),\n )\n .map((c) => {\n const count = Number(owned[c.CollectibleID!] ?? 0);\n return {\n id: c.CollectibleID!,\n name: c.DisplayName || c.CollectibleID!,\n icon: c.AssetPaths?.icon ?? \"star\",\n rarity: Number(c.Rarity ?? 1),\n count,\n spare: Math.max(0, count - 1),\n };\n });\n const have = cards.filter((c) => c.count > 0).length;\n return {\n setID,\n name: s.DisplayName || setID,\n cards,\n owned: have,\n total: cards.length,\n reward: grantLines(s.SetCompletionReward),\n state: claimed.includes(setID)\n ? \"claimed\"\n : cards.length > 0 && have === cards.length\n ? \"ready\"\n : \"collecting\",\n };\n });\n}\n\n/** The grand prize: claimable once every set is complete (\"none\" — the collection has no prize). */\nexport function grandPrizeState(\n collection: CollectionView | null | undefined,\n sets: SetRow[],\n state: CollectionStateView | null | undefined,\n): \"none\" | \"claimed\" | \"ready\" | \"locked\" {\n if (grantLines(collection?.GrandPrize).length === 0) return \"none\";\n if (state?.GrandPrizeClaimed) return \"claimed\";\n return sets.length > 0 && sets.every((s) => s.state !== \"collecting\")\n ? \"ready\"\n : \"locked\";\n}\n\n// ── Packs and chests ─────────────────────────────────────────────────────────────────────────\n\nexport interface PackOption {\n packTypeID: string;\n optionID: string | null;\n name: string;\n icon: string;\n cards: number;\n price: ResourceLine[];\n maxOpen: number;\n}\n\n/** Every way to open every pack type (one entry per price option; free packs have one entry). */\nexport function packOptions(\n section: CollectionDefinitions | null | undefined,\n): PackOption[] {\n const out: PackOption[] = [];\n for (const [id, p] of Object.entries(section?.PackTypes ?? {})) {\n if (!p) continue;\n const maxOpen = Number(\n p.MaxOpenCount ?? section?.Settings?.MaxPackOpenCount ?? 100,\n );\n if (maxOpen <= 0) continue;\n const base = {\n packTypeID: p.PackTypeID || id,\n name: p.DisplayName || p.PackTypeID || id,\n icon: p.AssetPaths?.icon ?? \"ticket\",\n cards: Number(p.CollectibleCount ?? 1),\n maxOpen,\n };\n const options = Object.entries(p.PriceOptions ?? {}).filter(([, o]) => o);\n if (options.length === 0) out.push({ ...base, optionID: null, price: [] });\n for (const [oid, o] of options)\n out.push({\n ...base,\n optionID: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n });\n }\n return out.sort(\n (a, b) =>\n a.packTypeID.localeCompare(b.packTypeID) ||\n (a.optionID ?? \"\").localeCompare(b.optionID ?? \"\"),\n );\n}\n\nexport interface ChestRow {\n chestID: string;\n name: string;\n icon: string;\n cost: number;\n cards: string;\n}\n\n/** Collection chests: bought with the collection's own currency (what duplicates turn into). */\nexport function chestRows(\n section: CollectionDefinitions | null | undefined,\n): ChestRow[] {\n return (\n (section?.CollectionChests ?? [])\n // The server may send a null entry in the list: skip it rather than fail the screen.\n .filter((c) => !!c?.CollectionChestID)\n .map((c) => {\n const min = Number(c.MinCollectibleCount ?? 1);\n const max = Number(c.MaxCollectibleCount ?? min);\n return {\n chestID: c.CollectionChestID!,\n name: c.DisplayName || c.CollectionChestID!,\n icon: c.AssetPaths?.icon ?? \"chest\",\n cost: Number(c.CollectionCurrencyCost ?? 0),\n cards: max > min ? `${min}–${max}` : `${min}`,\n };\n })\n );\n}\n\n/** Badge: set rewards and the grand prize waiting. */\nexport function collectionsBadge(\n collection: CollectionView | null | undefined,\n sets: SetRow[],\n state: CollectionStateView | null | undefined,\n): number {\n return (\n sets.filter((s) => s.state === \"ready\").length +\n (grandPrizeState(collection, sets, state) === \"ready\" ? 1 : 0)\n );\n}\n\n/** Whether the title has a collection — until it does, the lobby hides the tab. */\nexport function hasTitleData(\n section: CollectionDefinitions | undefined,\n): boolean {\n return Object.values(section?.Collections ?? {}).some(Boolean);\n}\n"
59
75
  },
60
76
  {
61
77
  "path": "module.ts",
62
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { CollectionsScreen, makeCollectionsWatcher } from \"./CollectionsScreen\";\nimport { hasTitleData, type CollectionSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Collections as a feature of the game: the album, packs and chests, trades with friends. The\n// watcher keeps the badge (set rewards and the grand prize waiting) — only on a title with a\n// collection.\nexport const collectionsModule: Module = defineModule({\n id: \"collections\",\n meta: { name: \"Collections\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"collections\",\n label: t(\"title\"),\n icon: \"star\",\n group: \"events\",\n order: 45,\n available: false,\n Screen: CollectionsScreen,\n });\n const enabled = hasTitleData(\n configSection<CollectionSection>(ctx.client, \"Collection\"),\n );\n ctx.features.setAvailable(\n \"collections\",\n enabled,\n \"no collection on the title (Collection)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCollectionsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"collections\" }),\n describeActions: {},\n });\n },\n});\n"
78
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { CollectionsScreen, watchCollections } from \"./CollectionsScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Collections as a feature of the game: the album, packs and chests, trades with friends. The\n// watcher keeps the badge (set rewards and the grand prize waiting) — only on a title with a\n// collection.\nexport const collectionsModule: Module = defineModule({\n id: \"collections\",\n meta: { name: \"Collections\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"collections\",\n label: t(\"title\"),\n icon: \"star\",\n group: \"events\",\n order: 45,\n available: false,\n Screen: CollectionsScreen,\n });\n const enabled = hasTitleData(configSection(ctx.client, \"Collection\"));\n ctx.features.setAvailable(\n \"collections\",\n enabled,\n \"no collection on the title (Collection)\",\n );\n if (enabled) watchCollections(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"collections\" }),\n describeActions: {},\n });\n },\n});\n"
63
79
  },
64
80
  {
65
81
  "path": "module.meta.json",
@@ -32,16 +32,16 @@
32
32
  "version": "0.1.1"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.21.1",
35
+ "@idosgames/core": "0.21.3",
36
36
  "@idosgames/module-sdk": "0.10.0",
37
- "@idosgames/react": "0.4.4",
37
+ "@idosgames/react": "0.4.5",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "ab28150d10234d119e4c757f55cc4fa9dbf6760ef75c8c6933538272d8f69669",
40
+ "contentHash": "9ea4219ca9392b210929339b84f6d13d6af2f55e54b92aab74608f3e7ac5467a",
41
41
  "files": [
42
42
  {
43
43
  "path": "CoopEventsScreen.tsx",
44
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { ActiveCoopEventInfo, IDosGamesClient } from \"@idosgames/core\";\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 Center,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n bulkSpins,\n chainIDs,\n coopBadge,\n eventOf,\n groupRows,\n spinPrices,\n type CoopEventView,\n type CoopSection,\n type GroupView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Team events (engine CoopEvent): the player joins a group (the server matches partners, bots fill\n// the gaps), each member spins the wheel to build their own object, and the group shares a grand\n// prize when every object is done. The group lives in ONE shared document on the server, so the\n// screen always re-reads it after an action instead of patching it locally.\n\n/** The player's group and the active events, shared by the screen and the badge (one read). */\nexport function createCoopStore(features: FeatureRegistry) {\n let group: GroupView | null | undefined = undefined;\n const listeners = new Set<() => void>();\n // One read at a time; a load asked for while one is in flight runs again right after it. Overlapping\n // reads (the screen, the badge, the re-read after a spin) used to be skipped by the transport as\n // duplicates, and a skipped read turned the player's group into \"no group\" — the join screen.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.coopEvent.getUserState();\n // A failed read keeps what is on screen; only the very first one ends the spinner.\n if (res.ok) group = (res.data.ActiveGroup as GroupView | null) ?? null;\n else if (group === undefined) group = null;\n const rows = groupRows(\n group,\n eventOf(configSection<CoopSection>(client, \"CoopEvent\"), group),\n client.auth.context?.userID,\n );\n features.setBadge(\"coop-events\", group ? coopBadge(rows) : 0);\n for (const l of listeners) l();\n };\n return {\n get: () => group,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n };\n}\nexport type CoopStore = ReturnType<typeof createCoopStore>;\n\nexport function makeCoopEventsScreen(store: CoopStore) {\n return function CoopEventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const group = useSyncExternalStore(store.subscribe, store.get, store.get);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (group === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n return group ? (\n <Group group={group} store={store} />\n ) : (\n <Join store={store} />\n );\n };\n}\n\n// ── Not in a group: the running events to join ───────────────────────────────────────────────\n\nfunction Join({ store }: { store: CoopStore }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const stagger = useStagger();\n const [active, setActive] = useState<ActiveCoopEventInfo[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n const chains = chainIDs(configSection<CoopSection>(client, \"CoopEvent\"));\n void Promise.all(\n chains.map((c) => client.coopEvent.getActiveEvent(c)),\n ).then((all) => setActive(all.flatMap((r) => (r.ok ? [r.data] : []))));\n }, [client]);\n\n const join = async (chainID: string) => {\n setBusy(chainID);\n const res = await client.coopEvent.joinOrCreateGroup(chainID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await store.load(client);\n };\n\n if (!active)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const running = active.filter((a) => a.EventDef);\n if (running.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noEvent\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {running.map((a, i) => {\n const def = a.EventDef as CoopEventView;\n return (\n <Card\n key={a.CoopChainID}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"green\"),\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"friends\" size={48} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(def.DisplayName ?? a.CoopChainID)}\n </div>\n <Timer\n at={a.ComputedEndUtc as string | undefined}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n {def.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {catalog.localize(def.Description)}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList\n lines={groupRows(null, def, null).grandPrize}\n size={20}\n />\n </div>\n <Button\n tone=\"green\"\n size=\"lg\"\n attract\n busy={busy === a.CoopChainID}\n onClick={() => void join(a.CoopChainID)}\n data-tutorial-anchor={`coop:join:${a.CoopChainID}`}\n >\n {t(\"join\")} · {Number(def.PartnerCount ?? 0) + 1} {t(\"players\")}\n </Button>\n </Card>\n );\n })}\n </div>\n );\n}\n\n// ── In a group ───────────────────────────────────────────────────────────────────────────────\n\nfunction Group({\n group,\n store,\n}: {\n group: GroupView;\n store: CoopStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [last, setLast] = useState<number | null>(null);\n const [leaving, setLeaving] = useState(false);\n const event = eventOf(configSection<CoopSection>(client, \"CoopEvent\"), group);\n const rows = groupRows(group, event, client.auth.context?.userID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const reload = useCallback(() => store.load(client), [client]);\n\n const spin = async (optionID: string | null, count: number) => {\n setBusy(`${optionID ?? \"\"}:${count}`);\n const res = await client.coopEvent.spin(\n group.CoopChainID ?? \"\",\n group.GroupID ?? \"\",\n count,\n optionID ?? undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n setLast(Number(res.data.ProgressDelta ?? 0));\n await reload();\n };\n const claim = async (kind: \"object\" | \"grand\") => {\n setBusy(kind);\n const res =\n kind === \"object\"\n ? await client.coopEvent.claimObjectReward(group.GroupID ?? \"\")\n : await client.coopEvent.claimGrandPrize(group.GroupID ?? \"\");\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n grantedBy(res.data),\n kind === \"grand\" ? { sound: \"levelUp\" } : undefined,\n );\n await reload();\n };\n const leave = async () => {\n setLeaving(false);\n const res = await client.coopEvent.leaveGroup(group.GroupID ?? undefined);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await reload();\n };\n\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(event?.DisplayName ?? group.CoopEventID ?? \"\")}\n </div>\n {group.ExpiresAtUtc && !rows.finished ? (\n <Timer at={group.ExpiresAtUtc} now={now} label={t(\"endsIn\")} />\n ) : null}\n {rows.forming ? (\n <div style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"forming\")}\n </div>\n ) : null}\n </div>\n\n <SectionTitle>{t(\"team\")}</SectionTitle>\n {rows.members.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{ display: \"grid\", gap: 6, opacity: m.left ? 0.5 : 1 }}\n >\n <div\n style={{ display: \"flex\", justifyContent: \"space-between\", gap: 8 }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {m.me ? t(\"you\") : m.name}\n {m.bot ? ` · ${t(\"bot\")}` : \"\"}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(m.objectName)}\n </span>\n </div>\n <ProgressBar\n value={m.progress}\n max={m.max}\n tone={m.done ? \"gold\" : \"green\"}\n />\n </Card>\n ))}\n\n {rows.mine && !rows.mine.done && !rows.finished ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n {last !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n color: v.green,\n textAlign: \"center\",\n }}\n >\n +{last} {t(\"progress\")}\n </div>\n ) : null}\n {spinPrices(event).map((o) => {\n const affordable = canAfford(o.price, balances);\n const bulk = bulkSpins(event);\n return (\n <div key={o.id ?? \"free\"} style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === `${o.id ?? \"\"}:1`}\n onClick={() => void spin(o.id, 1)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"coop:spin\"\n >\n {t(\"spin\")}{\" \"}\n {o.price.length > 0 ? (\n <ResourceList lines={o.price} size={18} />\n ) : null}\n </Button>\n {bulk > 0 ? (\n <Button\n tone=\"blue\"\n disabled={\n !canAfford(\n o.price.map((l) => ({ ...l, amount: l.amount * bulk })),\n balances,\n )\n }\n busy={busy === `${o.id ?? \"\"}:${bulk}`}\n onClick={() => void spin(o.id, bulk)}\n >\n ×{bulk}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n\n {rows.objectRewardReady ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"object\"}\n onClick={() => void claim(\"object\")}\n >\n {t(\"claimObject\")}{\" \"}\n <ResourceList lines={rows.objectReward} size={18} />\n </Button>\n ) : null}\n {rows.grandPrizeReady ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claim(\"grand\")}\n >\n {t(\"claimGrand\")} <ResourceList lines={rows.grandPrize} size={20} />\n </Button>\n ) : null}\n {!rows.grandPrizeReady ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => (rows.finished ? void leave() : setLeaving(true))}\n >\n {rows.finished ? t(\"close\") : t(\"leave\")}\n </Button>\n ) : null}\n {leaving ? (\n <Popup title={t(\"leave\")} onClose={() => setLeaving(false)} width={360}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"leaveConfirm\")}\n </div>\n <Button tone=\"red\" onClick={() => void leave()}>\n {t(\"leave\")}\n </Button>\n </Popup>\n ) : null}\n </div>\n );\n}\n\n/** Keeps the badge (object reward and grand prize waiting) — only on a title with team events. */\nexport function makeCoopWatcher(store: CoopStore) {\n return function CoopWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n void store.load(client);\n }, [client]);\n return null;\n };\n}\n"
44
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { ActiveCoopEventInfo, IDosGamesClient } from \"@idosgames/core\";\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 Center,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n bulkSpins,\n chainIDs,\n coopBadge,\n eventOf,\n groupRows,\n spinPrices,\n type CoopEventView,\n type GroupView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Team events (engine CoopEvent): the player joins a group (the server matches partners, bots fill\n// the gaps), each member spins the wheel to build their own object, and the group shares a grand\n// prize when every object is done. The group lives in ONE shared document on the server, so the\n// screen always re-reads it after an action instead of patching it locally.\n\n/** The player's group and the active events, shared by the screen and the badge (one read). */\nexport function createCoopStore(features: FeatureRegistry) {\n let group: GroupView | null | undefined = undefined;\n const listeners = new Set<() => void>();\n // One read at a time; a load asked for while one is in flight runs again right after it. Overlapping\n // reads (the screen, the badge, the re-read after a spin) used to be skipped by the transport as\n // duplicates, and a skipped read turned the player's group into \"no group\" — the join screen.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.coopEvent.getUserState();\n // A failed read keeps what is on screen; only the very first one ends the spinner.\n if (res.ok) group = (res.data.ActiveGroup as GroupView | null) ?? null;\n else if (group === undefined) group = null;\n const rows = groupRows(\n group,\n eventOf(configSection(client, \"CoopEvent\"), group),\n client.auth.context?.userID,\n );\n features.setBadge(\"coop-events\", group ? coopBadge(rows) : 0);\n for (const l of listeners) l();\n };\n return {\n get: () => group,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n };\n}\nexport type CoopStore = ReturnType<typeof createCoopStore>;\n\nexport function makeCoopEventsScreen(store: CoopStore) {\n return function CoopEventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const group = useSyncExternalStore(store.subscribe, store.get, store.get);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (group === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n return group ? (\n <Group group={group} store={store} />\n ) : (\n <Join store={store} />\n );\n };\n}\n\n// ── Not in a group: the running events to join ───────────────────────────────────────────────\n\nfunction Join({ store }: { store: CoopStore }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const stagger = useStagger();\n const [active, setActive] = useState<ActiveCoopEventInfo[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n const chains = chainIDs(configSection(client, \"CoopEvent\"));\n void Promise.all(\n chains.map((c) => client.coopEvent.getActiveEvent(c)),\n ).then((all) => setActive(all.flatMap((r) => (r.ok ? [r.data] : []))));\n }, [client]);\n\n const join = async (chainID: string) => {\n setBusy(chainID);\n const res = await client.coopEvent.joinOrCreateGroup(chainID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await store.load(client);\n };\n\n if (!active)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const running = active.filter((a) => a.EventDef);\n if (running.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noEvent\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {running.map((a, i) => {\n const def = a.EventDef as CoopEventView;\n return (\n <Card\n key={a.CoopChainID}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"green\"),\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"friends\" size={48} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(def.DisplayName ?? a.CoopChainID)}\n </div>\n <Timer\n at={a.ComputedEndUtc as string | undefined}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n {def.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {catalog.localize(def.Description)}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList\n lines={groupRows(null, def, null).grandPrize}\n size={20}\n />\n </div>\n <Button\n tone=\"green\"\n size=\"lg\"\n attract\n busy={busy === a.CoopChainID}\n onClick={() => void join(a.CoopChainID)}\n data-tutorial-anchor={`coop:join:${a.CoopChainID}`}\n >\n {t(\"join\")} · {Number(def.PartnerCount ?? 0) + 1} {t(\"players\")}\n </Button>\n </Card>\n );\n })}\n </div>\n );\n}\n\n// ── In a group ───────────────────────────────────────────────────────────────────────────────\n\nfunction Group({\n group,\n store,\n}: {\n group: GroupView;\n store: CoopStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [last, setLast] = useState<number | null>(null);\n const [leaving, setLeaving] = useState(false);\n const event = eventOf(configSection(client, \"CoopEvent\"), group);\n const rows = groupRows(group, event, client.auth.context?.userID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const reload = useCallback(() => store.load(client), [client]);\n\n const spin = async (optionID: string | null, count: number) => {\n setBusy(`${optionID ?? \"\"}:${count}`);\n const res = await client.coopEvent.spin(\n group.CoopChainID ?? \"\",\n group.GroupID ?? \"\",\n count,\n optionID ?? undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n setLast(Number(res.data.ProgressDelta ?? 0));\n await reload();\n };\n const claim = async (kind: \"object\" | \"grand\") => {\n setBusy(kind);\n const res =\n kind === \"object\"\n ? await client.coopEvent.claimObjectReward(group.GroupID ?? \"\")\n : await client.coopEvent.claimGrandPrize(group.GroupID ?? \"\");\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n grantedBy(res.data),\n kind === \"grand\" ? { sound: \"levelUp\" } : undefined,\n );\n await reload();\n };\n const leave = async () => {\n setLeaving(false);\n const res = await client.coopEvent.leaveGroup(group.GroupID ?? undefined);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await reload();\n };\n\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(event?.DisplayName ?? group.CoopEventID ?? \"\")}\n </div>\n {group.ExpiresAtUtc && !rows.finished ? (\n <Timer at={group.ExpiresAtUtc} now={now} label={t(\"endsIn\")} />\n ) : null}\n {rows.forming ? (\n <div style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"forming\")}\n </div>\n ) : null}\n </div>\n\n <SectionTitle>{t(\"team\")}</SectionTitle>\n {rows.members.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{ display: \"grid\", gap: 6, opacity: m.left ? 0.5 : 1 }}\n >\n <div\n style={{ display: \"flex\", justifyContent: \"space-between\", gap: 8 }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {m.me ? t(\"you\") : m.name}\n {m.bot ? ` · ${t(\"bot\")}` : \"\"}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(m.objectName)}\n </span>\n </div>\n <ProgressBar\n value={m.progress}\n max={m.max}\n tone={m.done ? \"gold\" : \"green\"}\n />\n </Card>\n ))}\n\n {rows.mine && !rows.mine.done && !rows.finished ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n {last !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n color: v.green,\n textAlign: \"center\",\n }}\n >\n +{last} {t(\"progress\")}\n </div>\n ) : null}\n {spinPrices(event).map((o) => {\n const affordable = canAfford(o.price, balances);\n const bulk = bulkSpins(event);\n return (\n <div key={o.id ?? \"free\"} style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === `${o.id ?? \"\"}:1`}\n onClick={() => void spin(o.id, 1)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"coop:spin\"\n >\n {t(\"spin\")}{\" \"}\n {o.price.length > 0 ? (\n <ResourceList lines={o.price} size={18} />\n ) : null}\n </Button>\n {bulk > 0 ? (\n <Button\n tone=\"blue\"\n disabled={\n !canAfford(\n o.price.map((l) => ({ ...l, amount: l.amount * bulk })),\n balances,\n )\n }\n busy={busy === `${o.id ?? \"\"}:${bulk}`}\n onClick={() => void spin(o.id, bulk)}\n >\n ×{bulk}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n\n {rows.objectRewardReady ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"object\"}\n onClick={() => void claim(\"object\")}\n >\n {t(\"claimObject\")}{\" \"}\n <ResourceList lines={rows.objectReward} size={18} />\n </Button>\n ) : null}\n {rows.grandPrizeReady ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claim(\"grand\")}\n >\n {t(\"claimGrand\")} <ResourceList lines={rows.grandPrize} size={20} />\n </Button>\n ) : null}\n {!rows.grandPrizeReady ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => (rows.finished ? void leave() : setLeaving(true))}\n >\n {rows.finished ? t(\"close\") : t(\"leave\")}\n </Button>\n ) : null}\n {leaving ? (\n <Popup title={t(\"leave\")} onClose={() => setLeaving(false)} width={360}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"leaveConfirm\")}\n </div>\n <Button tone=\"red\" onClick={() => void leave()}>\n {t(\"leave\")}\n </Button>\n </Popup>\n ) : null}\n </div>\n );\n}\n\n/** Keeps the badge (object reward and grand prize waiting) — only on a title with team events. */\nexport function watchCoop(ctx: ModuleContext, store: CoopStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n void store.load(client);\n });\n}\n"
45
45
  },
46
46
  {
47
47
  "path": "i18n.ts",
@@ -53,11 +53,11 @@
53
53
  },
54
54
  {
55
55
  "path": "model.ts",
56
- "content": "// Pure helpers of the team events screen — covered by model.test.ts. The group lives on the server\n// (one shared document per group); these only decide what the screen draws.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface CoopObjectView {\n Index?: number | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n MaxProgress?: number | null;\n CompletionReward?: ResourceGrant | null;\n}\n\nexport interface CoopEventView {\n CoopEventID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n PartnerCount?: number | null;\n BuildObjects?: {\n Objects?: CoopObjectView[] | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n MaxSpinsPerCall?: number | null;\n } | null;\n GrandPrize?: ResourceGrant | null;\n}\n\n/** The title's CoopEvent section, as far as the screen cares. */\nexport interface CoopSection {\n Chains?: Record<\n string,\n {\n CoopChainID?: string | null;\n DisplayName?: string | null;\n Events?: CoopEventView[] | null;\n } | null\n > | null;\n}\n\n/** The group's shared document (as the server returns it). */\nexport interface GroupView {\n GroupID?: string | null;\n CoopChainID?: string | null;\n CoopEventID?: string | null;\n Status?: string | null;\n ExpiresAtUtc?: string | null;\n Members?: Array<{\n UserID?: string | null;\n PublicData?: { Username?: string | null } | null;\n IsBot?: boolean | null;\n MemberStatus?: string | null;\n GrandPrizeClaimed?: boolean | null;\n BuildObjectsProgress?: {\n ObjectIndex?: number | null;\n ObjectCompletionRewardClaimed?: boolean | null;\n } | null;\n } | null> | null;\n BuildObjectsState?: {\n Objects?: Array<{\n Index?: number | null;\n OwnerUserID?: string | null;\n CurrentProgress?: number | null;\n MaxProgress?: number | null;\n IsCompleted?: boolean | null;\n } | null> | null;\n } | null;\n}\n\n// ── Chains ───────────────────────────────────────────────────────────────────────────────────\n\n/** Chain ids of the title (each runs its own schedule of team events). */\nexport function chainIDs(section: CoopSection | null | undefined): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(([, c]) => c)\n .map(([id, c]) => c!.CoopChainID || id)\n .sort();\n}\n\n/** The event definition the group plays (by its CoopEventID across the chains). */\nexport function eventOf(\n section: CoopSection | null | undefined,\n group: GroupView | null | undefined,\n): CoopEventView | null {\n for (const c of Object.values(section?.Chains ?? {}))\n for (const e of c?.Events ?? [])\n if (e?.CoopEventID && e.CoopEventID === group?.CoopEventID) return e;\n return null;\n}\n\n// ── The group ────────────────────────────────────────────────────────────────────────────────\n\nexport interface MemberRow {\n userID: string;\n name: string;\n me: boolean;\n bot: boolean;\n left: boolean;\n objectName: string;\n progress: number;\n max: number;\n done: boolean;\n}\n\nexport interface GroupRows {\n members: MemberRow[];\n /** The player's own object (null — not in this group). */\n mine: MemberRow | null;\n /** The player's object is complete and its reward is not taken yet. */\n objectRewardReady: boolean;\n objectReward: ResourceLine[];\n /** The whole group is done and the player has not taken the grand prize. */\n grandPrizeReady: boolean;\n grandPrize: ResourceLine[];\n /** Waiting for partners — the server matches the group. */\n forming: boolean;\n /** The event is over for the group (done, failed or expired). */\n finished: boolean;\n}\n\n/** The group as the screen draws it: every member with their object's progress. */\nexport function groupRows(\n group: GroupView | null | undefined,\n event: CoopEventView | null | undefined,\n myUserID: string | null | undefined,\n): GroupRows {\n const objects = group?.BuildObjectsState?.Objects ?? [];\n const defs = event?.BuildObjects?.Objects ?? [];\n const members: MemberRow[] = (group?.Members ?? [])\n .filter((m): m is NonNullable<typeof m> => !!m?.UserID)\n .map((m) => {\n const index = Number(m.BuildObjectsProgress?.ObjectIndex ?? -1);\n const obj =\n objects.find((o) => o?.OwnerUserID === m.UserID) ??\n objects.find((o) => Number(o?.Index) === index);\n const def = defs.find((d) => Number(d?.Index) === Number(obj?.Index));\n const max = Math.max(\n 1,\n Number(obj?.MaxProgress ?? def?.MaxProgress ?? 1),\n );\n return {\n userID: m.UserID!,\n name: m.PublicData?.Username || m.UserID!,\n me: m.UserID === myUserID,\n bot: m.IsBot === true,\n left: !!m.MemberStatus && m.MemberStatus !== \"Active\",\n objectName: def?.DisplayName || `#${Number(obj?.Index ?? index) + 1}`,\n progress: Math.min(max, Number(obj?.CurrentProgress ?? 0)),\n max,\n done: obj?.IsCompleted === true,\n };\n });\n const mine = members.find((m) => m.me) ?? null;\n const meRaw = (group?.Members ?? []).find((m) => m?.UserID === myUserID);\n const myObj = objects.find((o) => o?.OwnerUserID === myUserID);\n const myDef = defs.find((d) => Number(d?.Index) === Number(myObj?.Index));\n const status = group?.Status ?? \"\";\n return {\n members,\n mine,\n objectRewardReady:\n !!mine?.done &&\n meRaw?.BuildObjectsProgress?.ObjectCompletionRewardClaimed !== true &&\n grantLines(myDef?.CompletionReward).length > 0,\n objectReward: grantLines(myDef?.CompletionReward),\n grandPrizeReady:\n status === \"Completed\" &&\n !!meRaw &&\n meRaw.GrandPrizeClaimed !== true &&\n grantLines(event?.GrandPrize).length > 0,\n grandPrize: grantLines(event?.GrandPrize),\n forming: status === \"Forming\",\n finished: [\"Completed\", \"Failed\", \"Expired\"].includes(status),\n };\n}\n\n/** Ways to pay for a spin (currencies/items); empty — spins are free. */\nexport function spinPrices(\n event: CoopEventView | null | undefined,\n): Array<{ id: string | null; price: ResourceLine[] }> {\n const options = Object.entries(event?.BuildObjects?.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }));\n return options.length > 0 ? options : [{ id: null, price: [] }];\n}\n\n/** The bulk spin offered next to a single one: five, when the ceiling allows (platform default 25). */\nexport function bulkSpins(event: CoopEventView | null | undefined): number {\n const max = Number(event?.BuildObjects?.MaxSpinsPerCall ?? 25);\n return max >= 5 ? 5 : 0;\n}\n\n/** Badge: the object reward and the grand prize waiting. */\nexport function coopBadge(rows: GroupRows): number {\n return (rows.objectRewardReady ? 1 : 0) + (rows.grandPrizeReady ? 1 : 0);\n}\n\n/** Whether the title has team events — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: CoopSection | undefined): boolean {\n return chainIDs(section).length > 0;\n}\n"
56
+ "content": "// Pure helpers of the team events screen — covered by model.test.ts. The group lives on the server\n// (one shared document per group); these only decide what the screen draws.\n\nimport type { ResourceGrant, CoopEventDefinitions } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface CoopObjectView {\n Index?: number | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n MaxProgress?: number | null;\n CompletionReward?: ResourceGrant | null;\n}\n\nexport interface CoopEventView {\n CoopEventID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n PartnerCount?: number | null;\n BuildObjects?: {\n Objects?: CoopObjectView[] | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n MaxSpinsPerCall?: number | null;\n } | null;\n GrandPrize?: ResourceGrant | null;\n}\n\n/** The group's shared document (as the server returns it). */\nexport interface GroupView {\n GroupID?: string | null;\n CoopChainID?: string | null;\n CoopEventID?: string | null;\n Status?: string | null;\n ExpiresAtUtc?: string | null;\n Members?: Array<{\n UserID?: string | null;\n PublicData?: { Username?: string | null } | null;\n IsBot?: boolean | null;\n MemberStatus?: string | null;\n GrandPrizeClaimed?: boolean | null;\n BuildObjectsProgress?: {\n ObjectIndex?: number | null;\n ObjectCompletionRewardClaimed?: boolean | null;\n } | null;\n } | null> | null;\n BuildObjectsState?: {\n Objects?: Array<{\n Index?: number | null;\n OwnerUserID?: string | null;\n CurrentProgress?: number | null;\n MaxProgress?: number | null;\n IsCompleted?: boolean | null;\n } | null> | null;\n } | null;\n}\n\n// ── Chains ───────────────────────────────────────────────────────────────────────────────────\n\n/** Chain ids of the title (each runs its own schedule of team events). */\nexport function chainIDs(\n section: CoopEventDefinitions | null | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(([, c]) => c)\n .map(([id, c]) => c!.CoopChainID || id)\n .sort();\n}\n\n/** The event definition the group plays (by its CoopEventID across the chains). */\nexport function eventOf(\n section: CoopEventDefinitions | null | undefined,\n group: GroupView | null | undefined,\n): CoopEventView | null {\n for (const c of Object.values(section?.Chains ?? {}))\n for (const e of c?.Events ?? [])\n if (e?.CoopEventID && e.CoopEventID === group?.CoopEventID) return e;\n return null;\n}\n\n// ── The group ────────────────────────────────────────────────────────────────────────────────\n\nexport interface MemberRow {\n userID: string;\n name: string;\n me: boolean;\n bot: boolean;\n left: boolean;\n objectName: string;\n progress: number;\n max: number;\n done: boolean;\n}\n\nexport interface GroupRows {\n members: MemberRow[];\n /** The player's own object (null — not in this group). */\n mine: MemberRow | null;\n /** The player's object is complete and its reward is not taken yet. */\n objectRewardReady: boolean;\n objectReward: ResourceLine[];\n /** The whole group is done and the player has not taken the grand prize. */\n grandPrizeReady: boolean;\n grandPrize: ResourceLine[];\n /** Waiting for partners — the server matches the group. */\n forming: boolean;\n /** The event is over for the group (done, failed or expired). */\n finished: boolean;\n}\n\n/** The group as the screen draws it: every member with their object's progress. */\nexport function groupRows(\n group: GroupView | null | undefined,\n event: CoopEventView | null | undefined,\n myUserID: string | null | undefined,\n): GroupRows {\n const objects = group?.BuildObjectsState?.Objects ?? [];\n const defs = event?.BuildObjects?.Objects ?? [];\n const members: MemberRow[] = (group?.Members ?? [])\n .filter((m): m is NonNullable<typeof m> => !!m?.UserID)\n .map((m) => {\n const index = Number(m.BuildObjectsProgress?.ObjectIndex ?? -1);\n const obj =\n objects.find((o) => o?.OwnerUserID === m.UserID) ??\n objects.find((o) => Number(o?.Index) === index);\n const def = defs.find((d) => Number(d?.Index) === Number(obj?.Index));\n const max = Math.max(\n 1,\n Number(obj?.MaxProgress ?? def?.MaxProgress ?? 1),\n );\n return {\n userID: m.UserID!,\n name: m.PublicData?.Username || m.UserID!,\n me: m.UserID === myUserID,\n bot: m.IsBot === true,\n left: !!m.MemberStatus && m.MemberStatus !== \"Active\",\n objectName: def?.DisplayName || `#${Number(obj?.Index ?? index) + 1}`,\n progress: Math.min(max, Number(obj?.CurrentProgress ?? 0)),\n max,\n done: obj?.IsCompleted === true,\n };\n });\n const mine = members.find((m) => m.me) ?? null;\n const meRaw = (group?.Members ?? []).find((m) => m?.UserID === myUserID);\n const myObj = objects.find((o) => o?.OwnerUserID === myUserID);\n const myDef = defs.find((d) => Number(d?.Index) === Number(myObj?.Index));\n const status = group?.Status ?? \"\";\n return {\n members,\n mine,\n objectRewardReady:\n !!mine?.done &&\n meRaw?.BuildObjectsProgress?.ObjectCompletionRewardClaimed !== true &&\n grantLines(myDef?.CompletionReward).length > 0,\n objectReward: grantLines(myDef?.CompletionReward),\n grandPrizeReady:\n status === \"Completed\" &&\n !!meRaw &&\n meRaw.GrandPrizeClaimed !== true &&\n grantLines(event?.GrandPrize).length > 0,\n grandPrize: grantLines(event?.GrandPrize),\n forming: status === \"Forming\",\n finished: [\"Completed\", \"Failed\", \"Expired\"].includes(status),\n };\n}\n\n/** Ways to pay for a spin (currencies/items); empty — spins are free. */\nexport function spinPrices(\n event: CoopEventView | null | undefined,\n): Array<{ id: string | null; price: ResourceLine[] }> {\n const options = Object.entries(event?.BuildObjects?.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }));\n return options.length > 0 ? options : [{ id: null, price: [] }];\n}\n\n/** The bulk spin offered next to a single one: five, when the ceiling allows (platform default 25). */\nexport function bulkSpins(event: CoopEventView | null | undefined): number {\n const max = Number(event?.BuildObjects?.MaxSpinsPerCall ?? 25);\n return max >= 5 ? 5 : 0;\n}\n\n/** Badge: the object reward and the grand prize waiting. */\nexport function coopBadge(rows: GroupRows): number {\n return (rows.objectRewardReady ? 1 : 0) + (rows.grandPrizeReady ? 1 : 0);\n}\n\n/** Whether the title has team events — until it does, the lobby hides the tab. */\nexport function hasTitleData(\n section: CoopEventDefinitions | undefined,\n): boolean {\n return chainIDs(section).length > 0;\n}\n"
57
57
  },
58
58
  {
59
59
  "path": "module.ts",
60
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport {\n createCoopStore,\n makeCoopEventsScreen,\n makeCoopWatcher,\n} from \"./CoopEventsScreen\";\nimport { hasTitleData, type CoopSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Team events as a feature of the game. The watcher reads the player's group once on start and\n// keeps the badge (object reward and team prize waiting) — only on a title with team events.\nexport const coopEventsModule: Module = defineModule({\n id: \"coop-events\",\n meta: { name: \"Team event\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createCoopStore(ctx.features);\n ctx.features.register({\n id: \"coop-events\",\n label: t(\"title\"),\n icon: \"friends\",\n group: \"events\",\n order: 28,\n available: false,\n Screen: makeCoopEventsScreen(store),\n });\n const enabled = hasTitleData(\n configSection<CoopSection>(ctx.client, \"CoopEvent\"),\n );\n ctx.features.setAvailable(\n \"coop-events\",\n enabled,\n \"no team events on the title (CoopEvent chains)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCoopWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"coop-events\",\n group: store.get()?.GroupID ?? null,\n }),\n describeActions: {},\n });\n },\n});\n"
60
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport {\n createCoopStore,\n makeCoopEventsScreen,\n watchCoop,\n} from \"./CoopEventsScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Team events as a feature of the game. The watcher reads the player's group once on start and\n// keeps the badge (object reward and team prize waiting) — only on a title with team events.\nexport const coopEventsModule: Module = defineModule({\n id: \"coop-events\",\n meta: { name: \"Team event\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createCoopStore(ctx.features);\n ctx.features.register({\n id: \"coop-events\",\n label: t(\"title\"),\n icon: \"friends\",\n group: \"events\",\n order: 28,\n available: false,\n Screen: makeCoopEventsScreen(store),\n });\n const enabled = hasTitleData(configSection(ctx.client, \"CoopEvent\"));\n ctx.features.setAvailable(\n \"coop-events\",\n enabled,\n \"no team events on the title (CoopEvent chains)\",\n );\n if (enabled) watchCoop(ctx, store);\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"coop-events\",\n group: store.get()?.GroupID ?? null,\n }),\n describeActions: {},\n });\n },\n});\n"
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",