@idosgames/mcp 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +69 -25
  4. package/registry/index.json +895 -620
  5. package/registry/modules/ai.json +65 -0
  6. package/registry/modules/board-game.json +8 -8
  7. package/registry/modules/boosts.json +66 -0
  8. package/registry/modules/{character-ui.json → character.json} +8 -12
  9. package/registry/modules/chat.json +70 -0
  10. package/registry/modules/collections.json +69 -0
  11. package/registry/modules/coop-events.json +67 -0
  12. package/registry/modules/{craft-ui.json → craft.json} +8 -12
  13. package/registry/modules/{deal-offer-ui.json → deals.json} +7 -11
  14. package/registry/modules/events.json +72 -0
  15. package/registry/modules/{friends-ui.json → friends.json} +11 -14
  16. package/registry/modules/idle-rpg.json +7 -7
  17. package/registry/modules/inventory.json +67 -0
  18. package/registry/modules/invite.json +70 -0
  19. package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -12
  20. package/registry/modules/lootboxes.json +67 -0
  21. package/registry/modules/mailbox.json +71 -0
  22. package/registry/modules/marketplace.json +71 -0
  23. package/registry/modules/mini-game.json +69 -0
  24. package/registry/modules/multiplayer.json +74 -0
  25. package/registry/modules/premium.json +67 -0
  26. package/registry/modules/profile.json +70 -0
  27. package/registry/modules/{pvp-ui.json → pvp.json} +9 -13
  28. package/registry/modules/quests.json +69 -0
  29. package/registry/modules/rewards.json +66 -0
  30. package/registry/modules/season.json +71 -0
  31. package/registry/modules/{store-ui.json → store.json} +9 -13
  32. package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -12
  33. package/registry/modules/voxelcraft.json +15 -455
  34. package/registry/modules/workshop.json +10 -6
  35. package/registry/skills/blockchain-system.json +1 -1
  36. package/registry/skills/dev-test-loop.json +1 -1
  37. package/registry/skills/idosgames-compose-modules.json +2 -2
  38. package/registry/skills/idosgames-getting-started.json +2 -2
  39. package/registry/skills/idosgames-module-contract.json +2 -2
  40. package/registry/skills/idosgames-project-structure.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +2 -2
  42. package/registry/skills/mailbox-system.json +6 -0
  43. package/registry/skills/multiplayer-realtime.json +2 -2
  44. package/registry/skills/push-notifications.json +1 -1
  45. package/registry/skills/workshop-system.json +1 -1
  46. package/registry/modules/chat-ui.json +0 -74
  47. package/registry/modules/events-ui.json +0 -80
  48. package/registry/modules/game-hud.json +0 -104
  49. package/registry/modules/inventory-ui.json +0 -73
  50. package/registry/modules/marketplace-ui.json +0 -75
  51. package/registry/modules/profile-ui.json +0 -74
  52. package/registry/modules/referral-ui.json +0 -73
  53. package/registry/modules/rewards-ui.json +0 -75
  54. package/registry/modules/season-ui.json +0 -79
  55. package/registry/modules/showcase.json +0 -92
@@ -1,74 +0,0 @@
1
- {
2
- "id": "chat-ui",
3
- "meta": {
4
- "name": "Chat",
5
- "type": "app",
6
- "engine": "dom"
7
- },
8
- "catalog": {
9
- "type": "feature",
10
- "summary": "Chat for any game: channels as tabs, private chats, a feed with earlier messages, slow mode, and report / ignore / write privately in a popup.",
11
- "description": "A ready player chat (Unity V2/Runtime/UI/Chat) for ANY game, built on the @idosgames/react/ui kit and client.chat. The title's channels are tabs (names are localization keys) plus 'Private' — 1-on-1 chats (with friends when the title says so). The feed loads its history, pages back to earlier messages, shows the server's copy of what was sent (moderation may mask a word) and counts down the slow mode; a channel the player may not write to shows the server's reason. ONE poll covers every room and runs only while the screen is open, at the interval the server sets; rooms are marked read when the player leaves them. Popups: a message's actions (write privately, report with a reason, ignore the player) and the channel list to join or leave opt-in channels. Registers the 'chat' feature; features.open('chat', { userID }) opens a private chat (the friends card uses it). Its badge is the unread count, read once per session — a closed chat costs nothing. Ships title-seed.json: a world channel, an opt-in trade channel, private chats between friends.",
12
- "provides": [
13
- "chat channels as tabs",
14
- "private chats",
15
- "message feed with earlier messages",
16
- "slow mode countdown",
17
- "report, ignore and write privately",
18
- "join and leave channels",
19
- "chat badge",
20
- "demo channels"
21
- ],
22
- "tags": [
23
- "chat",
24
- "social",
25
- "messages",
26
- "dm",
27
- "moderation",
28
- "ui",
29
- "screen"
30
- ],
31
- "author": {
32
- "name": "iDos Games",
33
- "url": "https://idosgames.com"
34
- },
35
- "version": "0.1.0"
36
- },
37
- "dependencies": {
38
- "@idosgames/core": "0.19.0",
39
- "@idosgames/module-sdk": "0.5.0",
40
- "@idosgames/react": "0.3.0",
41
- "react": "19.2.7"
42
- },
43
- "contentHash": "3e02bb18c0e156cf14736377a4c0bde15149eab92138d7f0252a906377c53307",
44
- "files": [
45
- {
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 Icon,\n IconButton,\n Popup,\n Spinner,\n Tabs,\n errorText,\n outlined,\n panel,\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 void client.chat\n .getChannels()\n .then(\n (r) =>\n r.ok && features.setBadge(\"chat\", chatBadge(r.data.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 background: system\n ? \"rgba(255,200,60,.18)\"\n : mine\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.1)\",\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: \"rgba(255,255,255,.6)\",\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"
48
- },
49
- {
50
- "path": "i18n.ts",
51
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Chat\",\n direct: \"Private\",\n channels: \"Channels\",\n join: \"Join\",\n leave: \"Leave\",\n noMessages: \"No messages yet — say hi!\",\n noConversations: \"No private chats yet. Start one from a friend's card.\",\n disabled: \"Chat is turned off in this game.\",\n placeholder: \"Message\",\n send: \"Send\",\n wait: \"Wait\",\n sec: \"s\",\n older: \"Earlier messages\",\n muted: \"You are muted until\",\n report: \"Report\",\n reported: \"Thanks — the report was sent.\",\n alreadyReported: \"You already reported this message.\",\n ignore: \"Ignore player\",\n ignored: \"You won't see this player's messages any more.\",\n writePrivately: \"Write privately\",\n reason: \"Reason\",\n rSpam: \"Spam\",\n rAbuse: \"Abuse\",\n rScam: \"Scam\",\n rInappropriate: \"Inappropriate\",\n rOther: \"Other\",\n back: \"Back\",\n system: \"System\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Чат\",\n direct: \"Личные\",\n channels: \"Каналы\",\n join: \"Вступить\",\n leave: \"Выйти\",\n noMessages: \"Сообщений пока нет — поздоровайтесь!\",\n noConversations: \"Личных переписок пока нет. Начните её из карточки друга.\",\n disabled: \"Чат в этой игре выключен.\",\n placeholder: \"Сообщение\",\n send: \"Отправить\",\n wait: \"Подождите\",\n sec: \"с\",\n older: \"Ранние сообщения\",\n muted: \"Вы в муте до\",\n report: \"Пожаловаться\",\n reported: \"Спасибо — жалоба отправлена.\",\n alreadyReported: \"Вы уже жаловались на это сообщение.\",\n ignore: \"Игнорировать игрока\",\n ignored: \"Сообщения этого игрока вы больше не увидите.\",\n writePrivately: \"Написать лично\",\n reason: \"Причина\",\n rSpam: \"Спам\",\n rAbuse: \"Оскорбления\",\n rScam: \"Мошенничество\",\n rInappropriate: \"Неприемлемо\",\n rOther: \"Другое\",\n back: \"Назад\",\n system: \"Система\",\n});\n"
52
- },
53
- {
54
- "path": "index.ts",
55
- "content": "// @idosgames/mod-chat-ui — Chat for any game: channels as tabs, direct messages with friends, and message actions (report, ignore) in a popup.\n\nexport { chatUiModule } from \"./module\";\n"
56
- },
57
- {
58
- "path": "model.test.ts",
59
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n authorName,\n channelChoices,\n chatBadge,\n isSystem,\n lastCursor,\n mergeMessages,\n orderedChannels,\n sendable,\n slowModeLeft,\n unreadLabel,\n} from \"./model\";\n\nconst msg = (id: string, at: string, extra: Record<string, unknown> = {}) => ({\n MessageID: id,\n SentAt: at,\n SenderUserID: \"U\",\n Text: id,\n ...extra,\n});\n\ndescribe(\"chat\", () => {\n it(\"the seeded chat: a world channel everyone is in, an opt-in trade channel, DMs between friends\", () => {\n const chat = (\n seed as unknown as {\n Chat: {\n Channels: Record<string, never>;\n Direct: { IsEnabled: boolean; WhoCanMessage: string };\n };\n }\n ).Chat;\n expect(channelChoices(chat.Channels, [\"world\"])).toEqual([\n { id: \"world\", name: \"World\", joined: true },\n { id: \"trade\", name: \"Trade\", joined: false },\n ]);\n expect(chat.Direct).toMatchObject({\n IsEnabled: true,\n WhoCanMessage: \"Friends\",\n });\n });\n\n it(\"merges history and polls without duplicates, oldest first\", () => {\n const merged = mergeMessages(\n [msg(\"b\", \"2026-09-28T10:00:02Z\"), msg(\"a\", \"2026-09-28T10:00:01Z\")],\n [msg(\"b\", \"2026-09-28T10:00:02Z\"), msg(\"c\", \"2026-09-28T10:00:03Z\")],\n );\n expect(merged.map((m) => m.MessageID)).toEqual([\"a\", \"b\", \"c\"]);\n expect(lastCursor(\"world\", merged)).toEqual({\n RoomID: \"world\",\n SentAt: \"2026-09-28T10:00:03Z\",\n MessageID: \"c\",\n });\n expect(lastCursor(\"world\", [])).toBeUndefined();\n });\n\n it(\"an uncounted room is not zero, and big counts are capped\", () => {\n expect(unreadLabel(null)).toBe(\"\");\n expect(unreadLabel(0)).toBe(\"\");\n expect(unreadLabel(7)).toBe(\"7\");\n expect(unreadLabel(150)).toBe(\"99+\");\n expect(\n chatBadge([\n { UnreadCount: 2 },\n { UnreadCount: null },\n { UnreadCount: 3 },\n ]),\n ).toBe(5);\n });\n\n it(\"sends trimmed text within the limit and counts down the slow mode\", () => {\n expect(sendable(\" hi \", 200)).toBe(\"hi\");\n expect(sendable(\" \", 200)).toBeNull();\n expect(sendable(\"x\".repeat(201), 200)).toBeNull();\n expect(slowModeLeft(1_000, 3, 2_500)).toBe(2);\n expect(slowModeLeft(1_000, 3, 5_000)).toBe(0);\n expect(slowModeLeft(null, 3, 5_000)).toBe(0);\n });\n\n it(\"orders channels and names authors; system lines have no author to act on\", () => {\n expect(\n orderedChannels([\n { ChannelID: \"b\", SortOrder: 1 },\n { ChannelID: \"a\", SortOrder: 0 },\n ]).map((c) => c.ChannelID),\n ).toEqual([\"a\", \"b\"]);\n expect(\n authorName({\n SenderUserID: \"ABCDEFGHIJKLMN\",\n SenderProfile: { Username: \"Ann\" },\n }),\n ).toBe(\"Ann\");\n expect(authorName({ SenderUserID: \"ABCDEFGHIJKLMN\" })).toBe(\"ABCDE…KLMN\");\n expect(isSystem({ SenderUserID: null })).toBe(true);\n expect(isSystem({ SenderUserID: \"U\", AuthorType: \"Publisher\" })).toBe(true);\n expect(isSystem({ SenderUserID: \"U\", AuthorType: \"Player\" })).toBe(false);\n });\n});\n"
60
- },
61
- {
62
- "path": "model.ts",
63
- "content": "// Pure helpers of the chat screen (Unity V2/Runtime/UI/Chat): channel order, the feed (merging\n// history and polls without duplicates), read cursors, the slow mode and the badge. The server\n// decides rooms, moderation and who may write — nothing here re-checks it. Covered by model.test.ts.\n\nexport interface MessageView {\n RoomID?: string | null;\n MessageID?: string | null;\n SenderUserID?: string | null;\n SenderProfile?: { Username?: string | null } | null;\n AuthorType?: string | null;\n Text?: string | null;\n SentAt?: string | null;\n}\n\nexport interface ChannelView {\n ChannelID?: string | null;\n RoomID?: string | null;\n DisplayNameKey?: string | null;\n SortOrder?: number | null;\n CanWrite?: boolean | null;\n WriteBlockedReason?: string | null;\n SlowModeSeconds?: number | null;\n MaxLength?: number | null;\n UnreadCount?: number | null;\n}\n\nexport function orderedChannels<T extends ChannelView>(\n channels: readonly T[],\n): T[] {\n return [...channels].sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n String(a.ChannelID).localeCompare(String(b.ChannelID)),\n );\n}\n\n/** Badge text of a room: `null` from the server means \"not counted\", which is not zero. */\nexport function unreadLabel(count: number | null | undefined): string {\n if (count === null || count === undefined) return \"\";\n if (count <= 0) return \"\";\n return count > 99 ? \"99+\" : String(count);\n}\n\nconst FEED_LIMIT = 200;\n\n/** History and polled messages merged: no duplicates, oldest first, the newest FEED_LIMIT kept. */\nexport function mergeMessages(\n existing: readonly MessageView[],\n incoming: readonly MessageView[],\n): MessageView[] {\n const byID = new Map<string, MessageView>();\n for (const m of [...existing, ...incoming])\n if (m.MessageID) byID.set(m.MessageID, m);\n const sorted = [...byID.values()].sort(\n (a, b) =>\n String(a.SentAt ?? \"\").localeCompare(String(b.SentAt ?? \"\")) ||\n String(a.MessageID).localeCompare(String(b.MessageID)),\n );\n return sorted.slice(-FEED_LIMIT);\n}\n\n/** The read cursor up to the newest message drawn (a pair — neighbours share a millisecond). */\nexport function lastCursor(\n roomID: string,\n messages: readonly MessageView[],\n):\n | { RoomID: string; SentAt?: string | null; MessageID?: string | null }\n | undefined {\n const last = messages[messages.length - 1];\n return last\n ? { RoomID: roomID, SentAt: last.SentAt, MessageID: last.MessageID }\n : undefined;\n}\n\nexport function authorName(m: MessageView): string {\n const name = m.SenderProfile?.Username?.trim();\n if (name) return name;\n const id = m.SenderUserID ?? \"\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\n/** A publisher announcement or a system line: no author to report or ignore. */\nexport function isSystem(m: MessageView): boolean {\n return !m.SenderUserID || (m.AuthorType != null && m.AuthorType !== \"Player\");\n}\n\n/** What can be sent: trimmed, not empty, within the room's limit. */\nexport function sendable(\n text: string,\n maxLength: number | null | undefined,\n): string | null {\n const s = text.trim();\n if (!s) return null;\n return maxLength && s.length > maxLength ? null : s;\n}\n\n/** Seconds left of the slow mode after my last message in this room. */\nexport function slowModeLeft(\n lastSentMs: number | null | undefined,\n seconds: number | null | undefined,\n nowMs: number,\n): number {\n if (!lastSentMs || !seconds) return 0;\n return Math.max(0, Math.ceil((lastSentMs + seconds * 1000 - nowMs) / 1000));\n}\n\nexport interface ChannelChoice {\n id: string;\n name: string;\n joined: boolean;\n}\n\n/** Every channel of the title with whether the player is in it — for the join / leave list. */\nexport function channelChoices(\n definitions:\n | Record<\n string,\n {\n Identity?: {\n DisplayNameKey?: string | null;\n SortOrder?: number | null;\n } | null;\n Type?: string | null;\n } | null\n >\n | null\n | undefined,\n joinedIDs: readonly string[],\n): ChannelChoice[] {\n return Object.entries(definitions ?? {})\n .filter(([, d]) => d && d.Type !== \"Announcement\" && d.Type !== \"Group\")\n .sort(\n ([, a], [, b]) =>\n Number(a?.Identity?.SortOrder ?? 0) -\n Number(b?.Identity?.SortOrder ?? 0),\n )\n .map(([id, d]) => ({\n id,\n name: d?.Identity?.DisplayNameKey ?? id,\n joined: joinedIDs.includes(id),\n }));\n}\n\n/** Badge: counted unread messages across the rooms (uncounted rooms add nothing). */\nexport function chatBadge(\n rooms: ReadonlyArray<{ UnreadCount?: number | null }>,\n): number {\n return rooms.reduce((n, r) => n + Math.max(0, Number(r.UnreadCount ?? 0)), 0);\n}\n"
64
- },
65
- {
66
- "path": "module.ts",
67
- "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 chatUiModule: Module = defineModule({\n id: \"chat-ui\",\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 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"
68
- },
69
- {
70
- "path": "module.meta.json",
71
- "content": "{\n \"id\": \"chat-ui\",\n \"type\": \"feature\",\n \"summary\": \"Chat for any game: channels as tabs, private chats, a feed with earlier messages, slow mode, and report / ignore / write privately in a popup.\",\n \"description\": \"A ready player chat (Unity V2/Runtime/UI/Chat) for ANY game, built on the @idosgames/react/ui kit and client.chat. The title's channels are tabs (names are localization keys) plus 'Private' — 1-on-1 chats (with friends when the title says so). The feed loads its history, pages back to earlier messages, shows the server's copy of what was sent (moderation may mask a word) and counts down the slow mode; a channel the player may not write to shows the server's reason. ONE poll covers every room and runs only while the screen is open, at the interval the server sets; rooms are marked read when the player leaves them. Popups: a message's actions (write privately, report with a reason, ignore the player) and the channel list to join or leave opt-in channels. Registers the 'chat' feature; features.open('chat', { userID }) opens a private chat (the friends card uses it). Its badge is the unread count, read once per session — a closed chat costs nothing. Ships title-seed.json: a world channel, an opt-in trade channel, private chats between friends.\",\n \"provides\": [\n \"chat channels as tabs\",\n \"private chats\",\n \"message feed with earlier messages\",\n \"slow mode countdown\",\n \"report, ignore and write privately\",\n \"join and leave channels\",\n \"chat badge\",\n \"demo channels\"\n ],\n \"tags\": [\"chat\", \"social\", \"messages\", \"dm\", \"moderation\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
72
- }
73
- ]
74
- }
@@ -1,80 +0,0 @@
1
- {
2
- "id": "events-ui",
3
- "meta": {
4
- "name": "Events",
5
- "type": "app",
6
- "engine": "dom"
7
- },
8
- "catalog": {
9
- "type": "feature",
10
- "summary": "Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.",
11
- "description": "A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) and the VIP pass (its benefits and prices in currencies) are popups. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the showcase mini-game does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.",
12
- "provides": [
13
- "events screen with a tab per active event",
14
- "event timer and token progress bar",
15
- "milestone track with rewards",
16
- "VIP second prize and top-up after purchase",
17
- "claim all milestones",
18
- "how-to-earn popup with daily caps",
19
- "VIP pass purchase popup",
20
- "events badge",
21
- "demo weekly event"
22
- ],
23
- "tags": [
24
- "events",
25
- "live-ops",
26
- "limited-time",
27
- "milestones",
28
- "battle-pass",
29
- "vip",
30
- "ui",
31
- "screen"
32
- ],
33
- "author": {
34
- "name": "iDos Games",
35
- "url": "https://idosgames.com"
36
- },
37
- "version": "0.1.0"
38
- },
39
- "dependencies": {
40
- "@idosgames/core": "0.19.0",
41
- "@idosgames/module-sdk": "0.5.0",
42
- "@idosgames/react": "0.3.0",
43
- "react": "19.2.7"
44
- },
45
- "contentHash": "516eb5f792f385475c2ccb9cb831ea93e9e3958fc6baf0e44275a5fa7fb2bec1",
46
- "files": [
47
- {
48
- "path": "EventsScreen.tsx",
49
- "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, 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 Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { VipPopup } from \"./VipPopup\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned and the VIP pass are popups.\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const res = await client.timedEvent.getActiveEvents();\n events = res.ok ? (res.data.ActiveEvents ?? []) : [];\n badge();\n for (const l of listeners) l();\n },\n refreshBadge: badge,\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/** Keeps the badge current: loads the events once, then every few minutes and on premium changes. */\nexport function makeEventsWatcher(store: EventsStore) {\n return function EventsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => store.refreshBadge(), [state?.Premium]);\n return null;\n };\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [vip, setVip] = useState<string | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n background: \"linear-gradient(180deg, #e0672b 0%, #8a2c1a 100%)\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"flame\"}\n size={46}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(\n event.Content?.DisplayName ?? event.TimedEventID,\n )}\n </div>\n <Timer at={event.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n <Button size=\"sm\" tone=\"blue\" onClick={() => setSources(true)}>\n {t(\"howToEarn\")}\n </Button>\n </div>\n {event.Content?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: \"rgba(255,255,255,.85)\",\n }}\n >\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}\n >\n <Icon glyph=\"star\" size={16} /> {formatAmount(goal.earned)}{\" \"}\n {token}\n </span>\n <span>\n {goal.earned >= goal.goal\n ? t(\"allDone\")\n : `${t(\"nextPrize\")} ${formatAmount(goal.goal)}`}\n </span>\n </div>\n <ProgressBar\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => setVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <MilestoneCard\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n {vip ? <VipPopup premiumID={vip} onClose={() => setVip(null)} /> : null}\n </div>\n );\n}\n\nfunction MilestoneCard({\n event,\n row,\n index,\n earned,\n store,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claimable = claimableNow(row);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border: claimable\n ? `2px solid ${v.gold}`\n : row.featured\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" && row.vipState !== \"ready\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 48 }}>\n <Icon glyph={reached ? \"star\" : \"lock\"} size={24} />\n <span style={{ ...outlined, fontSize: 14 }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(row.name)}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipState === \"locked\" ? 0.5 : 1,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n {row.vipState === \"granted\" ? (\n <Icon glyph=\"check\" size={16} />\n ) : null}\n </div>\n ) : null}\n </div>\n <div data-index={index}>\n {claimable ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <Popup title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
50
- },
51
- {
52
- "path": "i18n.ts",
53
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Events\",\n noEvents: \"No events right now — check back soon.\",\n endsIn: \"Ends in\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n locked: \"Locked\",\n vip: \"VIP\",\n vipPrize: \"VIP prize\",\n vipTopUp: \"Claim VIP\",\n getVip: \"Get VIP\",\n vipActive: \"VIP active\",\n vipUntil: \"until\",\n vipBenefit:\n \"A second prize on every milestone — also for the ones you already claimed.\",\n buy: \"Buy\",\n howToEarn: \"How to earn\",\n playRound: \"Play a round\",\n perDay: \"per day\",\n shopping: \"Buy in the shop\",\n quests: \"Finish a quest\",\n login: \"Log in\",\n other: \"Other activity\",\n nextPrize: \"Next prize at\",\n allDone: \"All prizes collected!\",\n days: \"days\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Ивенты\",\n noEvents: \"Сейчас ивентов нет — загляните позже.\",\n endsIn: \"До конца\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n locked: \"Закрыто\",\n vip: \"VIP\",\n vipPrize: \"Приз VIP\",\n vipTopUp: \"Забрать VIP\",\n getVip: \"Купить VIP\",\n vipActive: \"VIP активен\",\n vipUntil: \"до\",\n vipBenefit: \"Второй приз на каждой вехе — и на тех, что вы уже забрали.\",\n buy: \"Купить\",\n howToEarn: \"Как получить\",\n playRound: \"Сыграйте раунд\",\n perDay: \"в день\",\n shopping: \"Покупка в магазине\",\n quests: \"Выполненное задание\",\n login: \"Вход в игру\",\n other: \"Другие действия\",\n nextPrize: \"Следующий приз на\",\n allDone: \"Все призы собраны!\",\n days: \"дн.\",\n});\n"
54
- },
55
- {
56
- "path": "index.ts",
57
- "content": "// @idosgames/mod-events-ui — Limited-time events for any game: event tabs, a milestone track with rewards, claim all and a VIP second prize.\n\nexport { eventsUiModule } from \"./module\";\n"
58
- },
59
- {
60
- "path": "model.test.ts",
61
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n claimedLines,\n claimableNow,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumKey,\n tokenSources,\n} from \"./model\";\n\n// The server resolves active events (definition content + the player's token progress); here one is\n// assembled from the seeded definition the way GetActiveEvents returns it.\ntype Def = { TimedEventID: string; Content: unknown };\nconst def = Object.values(\n (seed as unknown as { TimedEvent: { Definitions: Record<string, Def> } })\n .TimedEvent.Definitions,\n)[0]!;\nconst NOW = Date.parse(\"2026-09-28T12:00:00Z\");\nconst vip = {\n Subscriptions: { vip: { ExpirationDate: \"2026-10-28T00:00:00Z\" } },\n MaxActiveTier: 1,\n};\nconst event = (\n earned: number,\n claims: Record<string, { ClaimedAt?: string; PremiumTiers?: string[] }> = {},\n) =>\n ({\n Type: \"Cyclic\",\n TimedEventID: def.TimedEventID,\n Content: def.Content,\n Progress: {\n Balance: { TotalEarned: earned },\n Milestone: { Claims: claims },\n },\n }) as never;\n\ndescribe(\"events\", () => {\n it(\"reads the seeded festival: five milestones in order, each with a VIP prize\", () => {\n const rows = milestoneRows(event(0), null, NOW);\n expect(rows.map((r) => r.required)).toEqual([10, 50, 120, 250, 400]);\n expect(\n rows.every((r) => r.vip.length > 0 && r.vipPremiumID === \"vip\"),\n ).toBe(true);\n // The reward also moves the season: the status is a line of it like the coins.\n expect(rows[0]!.reward).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n { kind: \"token\", id: \"Season:main\", amount: 10 },\n ]);\n expect(rows[4]!.featured).toBe(true);\n // Each milestone moves the season: 10 status for the first, 50 for the crown.\n expect(\n rows.map((r) => r.reward.find((l) => l.kind === \"token\")?.amount),\n ).toEqual([10, 20, 30, 40, 50]);\n });\n\n it(\"a reached milestone is claimable; without a pass its VIP prize stays locked\", () => {\n const [first, second] = milestoneRows(event(12), null, NOW);\n expect(first).toMatchObject({ state: \"ready\", vipState: \"locked\" });\n expect(second).toMatchObject({ state: \"locked\", vipState: \"locked\" });\n expect(claimableNow(first!)).toBe(true);\n });\n\n it(\"a pass bought after the claim offers the VIP top-up, once\", () => {\n const claimed = { stars_10: { ClaimedAt: \"2026-09-27T10:00:00Z\" } };\n const row = milestoneRows(event(12, claimed), vip, NOW)[0]!;\n expect(row).toMatchObject({ state: \"claimed\", vipState: \"ready\" });\n expect(claimableNow(row)).toBe(true);\n const topped = {\n stars_10: { ClaimedAt: \"2026-09-27T10:00:00Z\", PremiumTiers: [\"vip#1\"] },\n };\n expect(milestoneRows(event(12, topped), vip, NOW)[0]).toMatchObject({\n state: \"claimed\",\n vipState: \"granted\",\n });\n });\n\n it(\"an expired pass is not a pass\", () => {\n expect(hasPremium(vip, \"vip\", 1, NOW)).toBe(true);\n expect(hasPremium(vip, \"vip\", 1, Date.parse(\"2026-11-01T00:00:00Z\"))).toBe(\n false,\n );\n expect(hasPremium({ MaxActiveTier: 2 }, null, 1, NOW)).toBe(true);\n expect(premiumKey({ RequiredPremiumID: \"vip\", MinPremiumTier: 1 })).toBe(\n \"vip#1\",\n );\n expect(premiumKey({ MinPremiumTier: 2 })).toBe(\"*#2\");\n });\n\n it(\"the bar counts from the last reached milestone to the next\", () => {\n const e = event(60);\n expect(nextGoal(e, milestoneRows(e, null, NOW))).toEqual({\n earned: 60,\n from: 50,\n goal: 120,\n });\n });\n\n it(\"the badge counts milestones and VIP top-ups waiting\", () => {\n expect(eventsBadge([event(55)], null, NOW)).toBe(2);\n expect(\n eventsBadge([event(55, { stars_10: { ClaimedAt: \"x\" } })], vip, NOW),\n ).toBe(2);\n expect(eventsBadge([event(5)], vip, NOW)).toBe(0);\n });\n\n it(\"stars come from rounds (capped per day), shop purchases and claimed quests\", () => {\n expect(tokenSources(event(0))).toEqual([\n { kind: \"CustomAction\", action: \"play_round\", amount: 5, dailyCap: 20 },\n { kind: \"StorePurchase\", action: null, amount: 2, dailyCap: 0 },\n { kind: \"QuestComplete\", action: null, amount: 10, dailyCap: 0 },\n ]);\n });\n});\n\ndescribe(\"claimed lines\", () => {\n it(\"sums the standard part and the VIP bundles of the applied operation\", () => {\n const coins = (n: number) => ({\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: n }],\n });\n const data = {\n Resources: {\n Grant: {\n Standard: coins(50),\n PremiumTiers: [{ RequiredPremiumID: \"vip\", Resources: coins(50) }],\n },\n },\n };\n expect(claimedLines(data)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n ]);\n expect(\n claimedLines({ Rewards: { Grant: { Standard: coins(10) } } }),\n ).toEqual([{ kind: \"currency\", id: \"CO\", amount: 10 }]);\n expect(claimedLines({})).toBeNull();\n });\n});\n"
62
- },
63
- {
64
- "path": "model.ts",
65
- "content": "// Pure helpers of the events screen (Unity Core/Window LimitedTimeEvent): the milestone track of an\n// active event, its VIP second prize, and what the player can claim. The server resolves which events\n// are active and is the authority on every claim. Covered by model.test.ts.\n\nimport type { ActiveEventInfo, MilestoneDefinition } from \"@idosgames/core\";\nimport {\n bundleLines,\n grantLines,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n/** The player's premium passes, as the client caches them (`state.Premium`). */\nexport interface PremiumView {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null\n > | null;\n MaxActiveTier?: number | null;\n}\n\ntype PremiumTier = {\n RequiredPremiumID?: string | null;\n MinPremiumTier?: number | null;\n Resources?: unknown;\n};\n\n/** Does the player hold the pass a premium bundle asks for (a named pass, or any pass of a tier)? */\nexport function hasPremium(\n premium: PremiumView | null | undefined,\n premiumID: string | null | undefined,\n minTier: number | null | undefined,\n nowMs: number,\n): boolean {\n if (premiumID) {\n const until = premium?.Subscriptions?.[premiumID]?.ExpirationDate;\n return !!until && new Date(until).getTime() > nowMs;\n }\n return (\n Number(premium?.MaxActiveTier ?? 0) >= Math.max(1, Number(minTier ?? 1))\n );\n}\n\n/** The server's key of a granted premium bundle: `{RequiredPremiumID|*}#{MinPremiumTier}`. */\nexport function premiumKey(tier: PremiumTier): string {\n return `${tier.RequiredPremiumID || \"*\"}#${Number(tier.MinPremiumTier ?? 0)}`;\n}\n\n/** Premium passes the rewards ask for (so the screen knows which pass to offer). */\nexport function premiumIDsOf(\n grants: Array<{ PremiumTiers?: PremiumTier[] | null } | null | undefined>,\n): string[] {\n const ids = new Set<string>();\n for (const g of grants)\n for (const t of g?.PremiumTiers ?? [])\n if (t?.RequiredPremiumID) ids.add(t.RequiredPremiumID);\n return [...ids];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string | null;\n required: number;\n featured: boolean;\n reward: ResourceLine[];\n /** The VIP second prize (empty when the milestone has none). */\n vip: ResourceLine[];\n /** Pass the VIP prize asks for (the first one). */\n vipPremiumID: string | null;\n state: \"claimed\" | \"ready\" | \"locked\";\n /**\n * none — no VIP prize; locked — the player has no pass; ready — the pass is held and the prize\n * can be taken (with the base, or topped up after it); granted — already taken.\n */\n vipState: \"none\" | \"locked\" | \"ready\" | \"granted\";\n}\n\nexport function eventEarned(e: ActiveEventInfo): number {\n return Number(e.Progress?.Balance?.TotalEarned ?? 0);\n}\n\n/** The event's milestones in threshold order, with what the player can do about each. */\nexport function milestoneRows(\n e: ActiveEventInfo,\n premium: PremiumView | null | undefined,\n nowMs: number,\n): MilestoneRow[] {\n const earned = eventEarned(e);\n const claims = e.Progress?.Milestone?.Claims ?? {};\n return Object.entries(e.Content?.Milestones ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const def = m as MilestoneDefinition;\n const id = def.MilestoneID || key;\n const claim = claims[id];\n const claimed = !!claim?.ClaimedAt;\n const reached = earned >= Number(def.RequiredProgress ?? 0);\n const tiers = (def.Rewards?.PremiumTiers ?? []) as PremiumTier[];\n const vip = tiers.flatMap((t) => bundleLines(t.Resources as never));\n const held = tiers.filter((t) =>\n hasPremium(premium, t.RequiredPremiumID, t.MinPremiumTier, nowMs),\n );\n const granted = claim?.PremiumTiers ?? [];\n const missing = held.filter((t) => !granted.includes(premiumKey(t)));\n let vipState: MilestoneRow[\"vipState\"] = \"locked\";\n if (tiers.length === 0) vipState = \"none\";\n else if (tiers.every((t) => granted.includes(premiumKey(t))))\n vipState = \"granted\";\n else if (missing.length > 0 && reached) vipState = \"ready\";\n return {\n id,\n name: def.DisplayName ?? null,\n required: Number(def.RequiredProgress ?? 0),\n featured: def.IsFeatured === true,\n reward: grantLines(def.Rewards),\n vip,\n vipPremiumID:\n tiers.find((t) => t.RequiredPremiumID)?.RequiredPremiumID ?? null,\n state: claimed ? \"claimed\" : reached ? \"ready\" : \"locked\",\n vipState,\n } satisfies MilestoneRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** What a tap on the row claims now: the base (with any held VIP part), or only the VIP top-up. */\nexport function claimableNow(row: MilestoneRow): boolean {\n return (\n row.state === \"ready\" ||\n (row.state === \"claimed\" && row.vipState === \"ready\")\n );\n}\n\n/** Progress toward the next unclaimed-or-unreached milestone, for the header bar. */\nexport function nextGoal(\n e: ActiveEventInfo,\n rows: MilestoneRow[],\n): { earned: number; goal: number; from: number } {\n const earned = eventEarned(e);\n const next = rows.find((r) => r.required > earned);\n const prev = [...rows].reverse().find((r) => r.required <= earned);\n return {\n earned,\n goal: next?.required ?? rows[rows.length - 1]?.required ?? earned,\n from: prev?.required ?? 0,\n };\n}\n\n/** Badge: milestones (or VIP top-ups) waiting across the active events. */\nexport function eventsBadge(\n events: ActiveEventInfo[],\n premium: PremiumView | null | undefined,\n nowMs: number,\n): number {\n return events.reduce(\n (n, e) => n + milestoneRows(e, premium, nowMs).filter(claimableNow).length,\n 0,\n );\n}\n\nexport interface TokenSourceView {\n kind: string;\n action: string | null;\n amount: number;\n dailyCap: number;\n}\n\n/** How the event's tokens are earned, for the \"how to get\" popup. */\nexport function tokenSources(e: ActiveEventInfo): TokenSourceView[] {\n return (e.Content?.TokenSources ?? []).map((s) => {\n const src = s as {\n SourceType?: string | null;\n BaseWeight?: number | null;\n Params?: Record<string, string> | null;\n Limits?: { DailyCap?: number | null } | null;\n };\n return {\n kind: String(src.SourceType ?? \"\"),\n action: src.Params?.ActionName ?? null,\n amount: Number(src.BaseWeight ?? 1),\n dailyCap: Number(src.Limits?.DailyCap ?? 0),\n };\n });\n}\n\n/**\n * Everything a claim paid, for the reward popup: the standard part plus every premium (VIP) bundle\n * of the applied operation, summed by resource. The generic reader takes only the standard part,\n * so a VIP prize paid with the base would not show. `null` = the response has no operation.\n */\nexport function claimedLines(data: unknown): ResourceLine[] | null {\n const d = data as\n { Resources?: OpView | null; Rewards?: OpView | null } | null | undefined;\n const op = d?.Resources ?? d?.Rewards;\n if (!op?.Grant) return null;\n const merged = new Map<string, ResourceLine>();\n const add = (lines: ResourceLine[]) => {\n for (const l of lines) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n };\n add(bundleLines(op.Grant.Standard as never));\n for (const t of op.Grant.PremiumTiers ?? [])\n add(bundleLines(t?.Resources as never));\n return [...merged.values()];\n}\ntype OpView = {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n};\n"
66
- },
67
- {
68
- "path": "module.ts",
69
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createEventsStore,\n makeEventsScreen,\n makeEventsWatcher,\n} from \"./EventsScreen\";\nimport { t } from \"./i18n\";\n\n// Limited-time events as a feature of the game. The always-on watcher keeps the badge current\n// (milestones and VIP top-ups waiting); the screen shows every active event as a tab.\nexport const eventsUiModule: Module = defineModule({\n id: \"events-ui\",\n meta: { name: \"Events\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createEventsStore(ctx.features);\n ctx.features.register({\n id: \"events\",\n label: t(\"title\"),\n icon: \"flame\",\n group: \"events\",\n order: 25,\n Screen: makeEventsScreen(store),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeEventsWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"events\",\n active: store.get()?.map((e) => e.TimedEventID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
70
- },
71
- {
72
- "path": "VipPopup.tsx",
73
- "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n canAfford,\n costLines,\n errorText,\n outlined,\n panel,\n usePopupClose,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The VIP pass (Unity PremiumPopUp): what it gives and a price option to buy it with currencies.\n// A pass paid in a store (real money) is not sold here — that is the platform's checkout.\n\n/** The part of a premium definition (config section Premium) the popup reads. */\ntype PremiumDefinition = {\n PremiumID?: string | null;\n DisplayName?: string | null;\n DurationDays?: number | null;\n Benefits?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n};\n\nexport function VipPopup({\n premiumID,\n onClose,\n}: {\n premiumID: string;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [def, setDef] = useState<PremiumDefinition | null | undefined>(\n undefined,\n );\n useEffect(() => {\n void client.premium\n .getDefinitions()\n .then((res) =>\n setDef(\n res.ok\n ? ((res.data.Premium?.Definitions?.[premiumID] as\n PremiumDefinition | undefined) ?? null)\n : null,\n ),\n );\n }, [client, premiumID]);\n return (\n <Popup\n title={def?.DisplayName ?? t(\"vip\")}\n onClose={onClose}\n width={420}\n variant=\"center\"\n >\n {def === undefined ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : def === null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n —\n </div>\n ) : (\n <VipOffer def={def} />\n )}\n </Popup>\n );\n}\n\nfunction VipOffer({ def }: { def: PremiumDefinition }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const options = Object.entries(def.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([id, o]) => ({\n id: o?.OptionID || id,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0);\n\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n def.PremiumID!,\n crypto.randomUUID(),\n optionID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n toast(t(\"vipActive\"), \"success\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={64} />\n </div>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"vipBenefit\")}\n </div>\n {Object.values(def.Benefits ?? {}).length > 0 ? (\n <div style={{ display: \"grid\", gap: 4, width: \"100%\" }}>\n {Object.values(def.Benefits ?? {}).map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {b}\n </div>\n ))}\n </div>\n ) : null}\n {Number(def.DurationDays ?? 0) > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {def.DurationDays} {t(\"days\")}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 8, width: \"100%\" }}>\n {options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor=\"vip:buy\"\n >\n {t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </div>\n </div>\n );\n}\n"
74
- },
75
- {
76
- "path": "module.meta.json",
77
- "content": "{\n \"id\": \"events-ui\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) and the VIP pass (its benefits and prices in currencies) are popups. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the showcase mini-game does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"VIP pass purchase popup\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
78
- }
79
- ]
80
- }
@@ -1,104 +0,0 @@
1
- {
2
- "id": "game-hud",
3
- "meta": {
4
- "name": "Game HUD",
5
- "type": "app",
6
- "engine": "dom"
7
- },
8
- "sharedUi": {
9
- "provides": [
10
- "currency-bar",
11
- "wallet",
12
- "status",
13
- "account",
14
- "menu"
15
- ]
16
- },
17
- "catalog": {
18
- "type": "feature",
19
- "summary": "Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.",
20
- "description": "The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.",
21
- "provides": [
22
- "one shared currency balance bar for every mode",
23
- "one crypto wallet button (deposit / withdraw) for the whole game",
24
- "shared status line / toast messages across modes",
25
- "player ID with copy and a log-out button",
26
- "consistent HUD when mixing several game templates"
27
- ],
28
- "tags": [
29
- "hud",
30
- "ui",
31
- "shared",
32
- "wallet",
33
- "currency",
34
- "status",
35
- "chrome"
36
- ],
37
- "author": {
38
- "name": "iDos Games",
39
- "url": "https://idosgames.com"
40
- },
41
- "version": "0.1.0",
42
- "events": {
43
- "listens": [
44
- {
45
- "topic": "host:mode-changed@1",
46
- "why": "clears the previous mode's status message when the player switches modes"
47
- }
48
- ]
49
- }
50
- },
51
- "dependencies": {
52
- "@idosgames/core": "0.19.0",
53
- "@idosgames/module-sdk": "0.5.0",
54
- "@idosgames/react": "0.3.0",
55
- "@idosgames/wallet": "0.5.1",
56
- "@tanstack/react-query": "5.101.2",
57
- "react": "19.2.7",
58
- "viem": "2.55.2",
59
- "wagmi": "3.7.2"
60
- },
61
- "contentHash": "08e902f30f360b5638c6432dd938b2b1cc120cc2bc9fbab9dde4ca926b914a5c",
62
- "files": [
63
- {
64
- "path": "AccountChip.tsx",
65
- "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The player's ID (click to copy) and \"Log out\". This HUD takes the \"account\" role, so the host does\n// not draw its own copy in the bottom-left corner. Logging out goes through the SDK: it emits\n// \"auth:loggedOut\", and the host returns to the login screen and tears the modules down.\n//\n// `compact` (a phone): one profile button with the ID and \"Log out\" in a dropdown under it — side by\n// side with the wallet they took the whole HUD row and pushed the balances into a column.\n\nexport function AccountChip({\n compact = false,\n}: {\n compact?: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const userID = client.auth.context?.userID ?? null;\n const [copied, setCopied] = useState(false);\n const [open, setOpen] = useState(false);\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n // Clipboard API needs a secure context; the drag-select fallback still works.\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n\n // Shown truncated, but the click always copies the COMPLETE id; hovering shows it in the tooltip.\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n\n const idButton = userID && (\n <button\n type=\"button\"\n style={idStyle}\n onClick={copy}\n title={`Copy your player ID: ${userID}`}\n >\n {copied ? \"✓ Copied\" : `ID: ${shortID} ⧉`}\n </button>\n );\n const logoutButton = (\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => void client.auth.logout()}\n title=\"Log out\"\n >\n 🚪 Log out\n </button>\n );\n\n if (!compact)\n return (\n <>\n {idButton}\n {logoutButton}\n </>\n );\n\n // The dropdown is absolutely positioned, so opening it does not change the HUD's height — the host\n // measures that height to place every mode below it.\n return (\n <div style={anchorStyle}>\n <button\n type=\"button\"\n style={profileStyle}\n onClick={() => setOpen((value) => !value)}\n aria-expanded={open}\n title=\"Account\"\n >\n 👤\n </button>\n {open && (\n <div style={dropdownStyle}>\n {idButton}\n {logoutButton}\n </div>\n )}\n </div>\n );\n}\n\nconst buttonStyle: CSSProperties = {\n border: \"none\",\n borderRadius: 12,\n padding: \"8px 14px\",\n fontWeight: 700,\n fontSize: 13,\n color: \"#cfe0ff\",\n background: \"rgba(4,32,90,0.72)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n whiteSpace: \"nowrap\",\n};\nconst idStyle: CSSProperties = {\n ...buttonStyle,\n fontWeight: 600,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n};\nconst profileStyle: CSSProperties = {\n ...buttonStyle,\n padding: \"6px 10px\",\n fontSize: 16,\n lineHeight: 1,\n};\nconst anchorStyle: CSSProperties = {\n position: \"relative\",\n pointerEvents: \"auto\",\n};\nconst dropdownStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n right: 0,\n zIndex: 5,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"stretch\",\n gap: 6,\n padding: 6,\n borderRadius: 14,\n background: \"rgba(4,32,90,0.92)\",\n boxShadow: \"0 8px 24px rgba(0,0,0,.35)\",\n};\n"
66
- },
67
- {
68
- "path": "CurrencyChips.tsx",
69
- "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { useCounterValue } from \"@idosgames/react/ui\";\n\n// The player's virtual-currency balances, one chip each. Labels come from the title's currency\n// configuration (`Currency.VirtualCurrencies[id].DisplayName`), translated through the title's\n// localization when that name is a localization key; a currency without a definition shows its id.\n\nexport function CurrencyChips(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n const balances = state?.InventoryV2?.VirtualCurrencies ?? {};\n // `currencyDefinitions`, not `getSection(\"Currency\")`: the latter holds only sections fetched on\n // their own (GetDefinitions), so on a fresh sign-in it was empty and the chips showed raw ids.\n const definitions =\n client.data.config.currencyDefinitions?.VirtualCurrencies ?? {};\n\n const label = (id: string): string => {\n const name = definitions[id]?.DisplayName;\n if (!name) return id;\n // DisplayName is either plain text or a localization key — translate only when it IS a key, so\n // a title with MissingKeyMode \"ReturnMarkedKey\" does not show ⟦Gold⟧.\n return client.localization.has(name) ? client.localization.t(name) : name;\n };\n\n const entries = Object.entries(balances);\n if (entries.length === 0) return null;\n\n return (\n <>\n {entries.map(([id, currency]) => (\n <Chip\n key={id}\n id={id}\n label={label(id)}\n amount={Number(currency.Amount ?? 0)}\n />\n ))}\n </>\n );\n}\n\n// `data-resource-counter` makes the chip a target of the kit's reward animation: icons of a claimed\n// reward fly here, and the number holds until they land, then ticks up (useCounterValue).\nfunction Chip({\n id,\n label,\n amount,\n}: {\n id: string;\n label: string;\n amount: number;\n}): ReactNode {\n const shown = useCounterValue(id, amount);\n return (\n <span data-resource-counter={id} style={chipStyle} title={id}>\n {label}: {shown}\n </span>\n );\n}\n\nconst chipStyle: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n whiteSpace: \"nowrap\",\n pointerEvents: \"auto\",\n};\n"
70
- },
71
- {
72
- "path": "FeatureMenu.tsx",
73
- "content": "import {\n useEffect,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n FeatureGrid,\n IconButton,\n Popup,\n Window,\n uiT,\n} from \"@idosgames/react/ui\";\n\n// ☰ in the HUD: this module took the \"menu\" shared-UI role, so the game's features (ctx.features —\n// the shop, the marketplace, friends…) are listed here in every mode, and a picked feature's screen\n// opens in a full-screen window. It is also the presenter of `ctx.features.open(id)`, so \"not enough\n// coins → open the shop\" works from any module.\n\nexport function FeatureMenu({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n const list = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const [menuOpen, setMenuOpen] = useState(false);\n const [screen, setScreen] = useState<{\n id: string;\n args?: Record<string, unknown>;\n } | null>(null);\n\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setMenuOpen(false);\n setScreen({ id, args });\n return true;\n }),\n [features],\n );\n\n if (list.length === 0) return null;\n const badges = list.reduce((sum, f) => sum + (f.badge > 0 ? 1 : 0), 0);\n const current = screen ? features.get(screen.id) : undefined;\n const Screen = current?.Screen;\n\n return (\n <>\n <span style={{ pointerEvents: \"auto\" }}>\n <IconButton\n glyph=\"menu\"\n label={uiT(\"menu\")}\n onClick={() => setMenuOpen(true)}\n size={38}\n tone=\"blue\"\n badge={badges}\n />\n </span>\n {menuOpen ? (\n <Popup\n title={uiT(\"menu\")}\n onClose={() => setMenuOpen(false)}\n width={520}\n >\n <FeatureGrid\n items={list}\n onOpen={(id) => {\n setMenuOpen(false);\n setScreen({ id });\n }}\n />\n </Popup>\n ) : null}\n {current && Screen ? (\n <Window\n key={current.id}\n title={current.label}\n icon={current.icon}\n onClose={() => setScreen(null)}\n >\n <Screen args={screen?.args} close={() => setScreen(null)} />\n </Window>\n ) : null}\n </>\n );\n}\n"
74
- },
75
- {
76
- "path": "GameHud.tsx",
77
- "content": "import {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { useStatus } from \"@idosgames/react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport type { ModeStore } from \"./modeStore\";\nimport { FeatureMenu } from \"./FeatureMenu\";\nimport { CurrencyChips } from \"./CurrencyChips\";\nimport { WalletButton } from \"./WalletButton\";\nimport { StatusToast } from \"./StatusToast\";\nimport { AccountChip } from \"./AccountChip\";\n\n// The HUD row: balances on the left, wallet + account on the right, the status toast floating under\n// the middle. It renders in the host's \"hud\" slot, which is a flex row across the top of the screen;\n// the host measures that row and moves every mode's overlay below it, so templates never slide under\n// the HUD.\n//\n// Pointer events: the row itself lets clicks through to the game (voxelcraft needs the canvas), only\n// the actual controls take them.\n\n/** Which parts this HUD draws — the shared-UI roles it won (see module.ts). */\nexport interface HudParts {\n currencies: boolean;\n wallet: boolean;\n status: boolean;\n account: boolean;\n /** ☰ — the menu of the game's features (ctx.features). */\n menu: boolean;\n}\n\nexport function makeGameHudPanel(\n mode: ModeStore,\n parts: HudParts,\n features: FeatureRegistry,\n): ComponentType {\n return function GameHudPanel(): ReactNode {\n return <GameHud mode={mode} parts={parts} features={features} />;\n };\n}\n\nexport function GameHud({\n mode,\n parts,\n features,\n}: {\n mode: ModeStore;\n parts: HudParts;\n features: FeatureRegistry;\n}): ReactNode {\n const current = useSyncExternalStore(mode.subscribe, mode.get);\n const { clear } = useStatus();\n\n // A status message belongs to the mode that wrote it: the board's \"Rolling…\" must not linger over\n // Idle RPG. (Hide parts per mode here too if a mode does not need them — `current` is its id.)\n useEffect(() => {\n clear();\n }, [current, clear]);\n\n // On a phone the ID and \"Log out\" fold into one profile button: side by side with the wallet\n // they took the whole row and pushed the balances into a column.\n const barRef = useRef<HTMLDivElement>(null);\n const compact = useNarrow(barRef, COMPACT_BELOW_PX);\n\n return (\n <div ref={barRef} style={barStyle} data-mode={current ?? undefined}>\n <div style={balancesSideStyle}>\n {parts.currencies && <CurrencyChips />}\n </div>\n <div style={controlsSideStyle}>\n {parts.menu && <FeatureMenu features={features} />}\n {parts.wallet && <WalletButton />}\n {parts.account && <AccountChip compact={compact} />}\n </div>\n {parts.status && <StatusToast />}\n </div>\n );\n}\n\nconst barStyle: CSSProperties = {\n flex: 1,\n minWidth: 0,\n position: \"relative\",\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"flex-start\",\n gap: 12,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n pointerEvents: \"none\",\n};\n/** Narrower than this, the account row folds into a profile button. */\nconst COMPACT_BELOW_PX = 600;\n\n/** True while the element is narrower than `px` (its own width, not the window's). */\nfunction useNarrow(ref: { current: HTMLElement | null }, px: number): boolean {\n const [narrow, setNarrow] = useState(false);\n useLayoutEffect(() => {\n const el = ref.current;\n if (!el) return;\n const measure = (): void =>\n setNarrow(el.getBoundingClientRect().width < px);\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [ref, px]);\n return narrow;\n}\n\nconst sideStyle: CSSProperties = {\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 8,\n minWidth: 0,\n};\nconst balancesSideStyle: CSSProperties = { ...sideStyle, flex: \"1 1 auto\" };\nconst controlsSideStyle: CSSProperties = {\n ...sideStyle,\n flex: \"0 1 auto\",\n justifyContent: \"flex-end\",\n};\n"
78
- },
79
- {
80
- "path": "index.ts",
81
- "content": "// @idosgames/mod-game-hud — the shared HUD for games made of several modules.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// after copying this into src/modules/.\n\nexport { gameHudModule } from \"./module\";\n\nexport { GameHud, makeGameHudPanel, type HudParts } from \"./GameHud\";\nexport { CurrencyChips } from \"./CurrencyChips\";\nexport { WalletButton } from \"./WalletButton\";\nexport { StatusToast } from \"./StatusToast\";\nexport { AccountChip } from \"./AccountChip\";\nexport { createModeStore, type ModeStore } from \"./modeStore\";\n"
82
- },
83
- {
84
- "path": "modeStore.ts",
85
- "content": "// The active mode, as announced by the host (`host:mode-changed@1`). A store, not React state: the\n// subscription lives in `setup()` for the whole session, the panel reads it with\n// useSyncExternalStore.\n\nexport interface ModeStore {\n subscribe(listener: () => void): () => void;\n get(): string | null;\n set(mode: string): void;\n}\n\nexport function createModeStore(): ModeStore {\n let current: string | null = null;\n const listeners = new Set<() => void>();\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n get: () => current,\n set(mode) {\n if (mode === current) return;\n current = mode;\n for (const listener of [...listeners]) listener();\n },\n };\n}\n"
86
- },
87
- {
88
- "path": "module.ts",
89
- "content": "import { defineModule, hostTopics, type Module } from \"@idosgames/module-sdk\";\nimport { createModeStore } from \"./modeStore\";\nimport { makeGameHudPanel, type HudParts } from \"./GameHud\";\n\n// The Game HUD feature module: no scene, no route — one HUD panel that stays on screen in every mode\n// and draws the chrome every mode shares. It declares every shared-UI role, so templates mixed into\n// the same game ask `ctx.sharedUi.shouldDraw(role)`, get `false` and hide their own copies.\nexport const gameHudModule: Module = defineModule({\n id: \"game-hud\",\n meta: {\n name: \"Game HUD\",\n type: \"app\",\n engine: \"dom\",\n },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"status\", \"account\", \"menu\"],\n },\n setup(ctx) {\n // Draw only the roles this module actually won: if another provider comes first in\n // src/modules.ts and takes, say, \"wallet\", this HUD hides its wallet button like any template.\n const parts: HudParts = {\n currencies: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n account: ctx.sharedUi.shouldDraw(\"account\"),\n menu: ctx.sharedUi.shouldDraw(\"menu\"),\n };\n\n // Subscribed here, not in the panel: setup() runs before the shell mounts, so even the first\n // activation is seen. The panel reads the current mode from the store.\n const mode = createModeStore();\n ctx.events.on(hostTopics.modeChanged, ({ to }) => mode.set(to));\n\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: makeGameHudPanel(mode, parts, ctx.features),\n });\n\n ctx.exposeToAgent({\n state: () => ({ mode: mode.get(), draws: parts }),\n describeActions: {},\n });\n },\n});\n"
90
- },
91
- {
92
- "path": "StatusToast.tsx",
93
- "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"@idosgames/react\";\n\n// The one status line for the whole game: whatever any module writes with `useStatus().setStatus`\n// lands here, because the host wraps every module in a single root StatusProvider. Floats under the\n// HUD (absolute), so a message appearing does not push the modes' layout down. Click to dismiss; the\n// HUD also clears it on every mode switch.\n\nconst colors: Record<string, string> = {\n info: \"#a9c6ff\",\n success: \"#5ad19a\",\n error: \"#ff6b6b\",\n};\n\nexport function StatusToast(): ReactNode {\n const { message, clear } = useStatus();\n if (!message) return null;\n return (\n <button\n type=\"button\"\n style={{ ...toastStyle, color: colors[message.kind] ?? \"#fff\" }}\n onClick={clear}\n title=\"Dismiss\"\n >\n {message.kind === \"error\" ? \"⚠ \" : message.kind === \"success\" ? \"✓ \" : \"\"}\n {message.text}\n </button>\n );\n}\n\nconst toastStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n maxWidth: \"min(560px, 80vw)\",\n border: \"none\",\n borderRadius: 12,\n padding: \"6px 14px\",\n fontSize: 13,\n fontWeight: 600,\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.85)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n};\n"
94
- },
95
- {
96
- "path": "WalletButton.tsx",
97
- "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { LazyWalletPanel } from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The one wallet entry for the whole game (deposit/withdraw), opened as a dropdown under the button.\n// The dropdown is absolutely positioned, so opening it does not change the HUD's height — the host\n// measures that height to place every mode below it.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls Reown\n// AppKit into the startup graph and blanks the preview for web3 titles. The wallet config is memoised\n// per project id, so a wallet connected on the sign-in screen is already connected here.\n\nexport function WalletButton(): ReactNode {\n const client = useIDosGamesClient();\n const [open, setOpen] = useState(false);\n\n return (\n <div style={anchorStyle}>\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => setOpen((value) => !value)}\n >\n 💰 Wallet\n </button>\n {open && (\n <div style={dropdownStyle}>\n <LazyWalletPanel\n client={client}\n appName=\"iDosGames\"\n onClose={() => setOpen(false)}\n />\n </div>\n )}\n </div>\n );\n}\n\nconst anchorStyle: CSSProperties = {\n position: \"relative\",\n pointerEvents: \"auto\",\n};\nconst buttonStyle: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst dropdownStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n right: 0,\n zIndex: 5,\n};\n"
98
- },
99
- {
100
- "path": "module.meta.json",
101
- "content": "{\n \"id\": \"game-hud\",\n \"type\": \"feature\",\n \"summary\": \"Shared game HUD: one currency bar, wallet button, status line and account chip across every mode.\",\n \"description\": \"The shared chrome for a game made of several modules. It takes every shared-UI role (currency-bar, wallet, status, account), so templates mixed into the same game hide their own balances, wallet and status line automatically — without editing them. Stays on screen in every mode (a HUD panel with activeOnly: false), clears the status on each mode switch, and is ordinary editable React: restyle or replace it like any other module. Installed automatically when a project starts from two or more templates.\",\n \"provides\": [\n \"one shared currency balance bar for every mode\",\n \"one crypto wallet button (deposit / withdraw) for the whole game\",\n \"shared status line / toast messages across modes\",\n \"player ID with copy and a log-out button\",\n \"consistent HUD when mixing several game templates\"\n ],\n \"tags\": [\"hud\", \"ui\", \"shared\", \"wallet\", \"currency\", \"status\", \"chrome\"],\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\",\n \"events\": {\n \"listens\": [\n {\n \"topic\": \"host:mode-changed@1\",\n \"why\": \"clears the previous mode's status message when the player switches modes\"\n }\n ]\n }\n}\n"
102
- }
103
- ]
104
- }