@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
@@ -0,0 +1,70 @@
1
+ {
2
+ "id": "invite",
3
+ "meta": {
4
+ "name": "Invite",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.",
12
+ "description": "A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature — always shown in the lobby; while the title has invites off (Referral.IsEnabled, off by default) the screen says so and makes no request. Its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.",
13
+ "provides": [
14
+ "own invite code and link",
15
+ "share or copy the invite",
16
+ "friends who came by your code",
17
+ "invite reward ladder with claim all",
18
+ "enter a friend's code popup",
19
+ "invite badge",
20
+ "demo referral rewards"
21
+ ],
22
+ "tags": [
23
+ "referral",
24
+ "invite",
25
+ "friends",
26
+ "growth",
27
+ "social",
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.1",
39
+ "@idosgames/module-sdk": "0.7.0",
40
+ "@idosgames/react": "0.3.0",
41
+ "react": "19.2.7"
42
+ },
43
+ "contentHash": "9e550203e022c98c4f8292d4f484bed6dae6abd259fff5cd8d0c4ec972371e6e",
44
+ "files": [
45
+ {
46
+ "path": "i18n.ts",
47
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Invite\",\n disabled: \"Invites are turned off in this game.\",\n yourCode: \"Your invite code\",\n copyCode: \"Copy code\",\n share: \"Share link\",\n copied: \"Copied\",\n joined: \"Friends who came by your code\",\n rewards: \"Rewards for friends\",\n friends: \"friend|friends\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n enterCode: \"Enter a friend's code\",\n enterHint: \"Got a code from a friend? Enter it and you both get a gift.\",\n youGet: \"You get\",\n activate: \"Activate\",\n badCode: \"A code is 8 letters, like WDJB-MJHT\",\n activated: \"Code activated!\",\n youCameBy: \"You joined by a friend's code\",\n inviteText: \"Join me in the game! My code:\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Пригласить\",\n disabled: \"Приглашения в этой игре выключены.\",\n yourCode: \"Ваш код приглашения\",\n copyCode: \"Скопировать код\",\n share: \"Поделиться ссылкой\",\n copied: \"Скопировано\",\n joined: \"Пришли по вашему коду\",\n rewards: \"Награды за друзей\",\n friends: \"друг|друга|друзей\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n enterCode: \"Ввести код друга\",\n enterHint: \"Друг дал вам код? Введите его — подарок получите вы оба.\",\n youGet: \"Вы получите\",\n activate: \"Активировать\",\n badCode: \"Код — это 8 букв, например WDJB-MJHT\",\n activated: \"Код активирован!\",\n youCameBy: \"Вы пришли по коду друга\",\n inviteText: \"Заходи в игру! Мой код:\",\n});\n"
48
+ },
49
+ {
50
+ "path": "index.ts",
51
+ "content": "// @idosgames/mod-invite — Invite friends for any game: your code and link, entering a friend's code, and rewards for friends who joined.\n\nexport { inviteModule } from \"./module\";\n"
52
+ },
53
+ {
54
+ "path": "model.ts",
55
+ "content": "// Pure helpers of the invite screen (Unity V2/Runtime/UI/Referral): the code a player types, the\n// ladder of rewards for friends who came by your code, and the badge. The server validates codes,\n// counts friends and pays — this only decides what the screen shows. Covered by model.test.ts.\n\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n/** The code alphabet: no vowels, no 0/O, no 1/I/L — so a code read aloud is typed right. */\nconst CODE_ALPHABET = /^[BCDFGHJKMNPQRSTVWXZ]{8}$/;\n\n/** A code as the player typed it: separators dropped, upper case; `null` when it can't be a code. */\nexport function normalizeCode(input: string): string | null {\n const code = input.replace(/[\\s\\-_.]/g, \"\").toUpperCase();\n return CODE_ALPHABET.test(code) ? code : null;\n}\n\n/** \"WDJB-MJHT\" — easier to read out and copy by eye. */\nexport function prettyCode(code: string | null | undefined): string {\n if (!code) return \"\";\n return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n}\n\nexport interface InviteRow {\n id: string;\n name: string | null;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function inviteRows(\n rewards:\n | Record<\n string,\n {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: unknown;\n } | null\n >\n | null\n | undefined,\n state:\n | {\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n }\n | null\n | undefined,\n): InviteRow[] {\n const friends = Number(state?.FollowersCount ?? 0);\n return Object.entries(rewards ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const id = m!.MilestoneID || key;\n const required = Number(m!.RequiredProgress ?? 0);\n const claimed = state?.InviteRewardStates?.[id]?.IsClaimed === true;\n return {\n id,\n name: m!.DisplayName ?? null,\n required,\n reward: grantLines(m!.Rewards as never),\n state: claimed ? \"claimed\" : friends >= required ? \"ready\" : \"locked\",\n } satisfies InviteRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** Badge: invite rewards ready to take. */\nexport function referralBadge(rows: InviteRow[]): number {\n return rows.filter((r) => r.state === \"ready\").length;\n}\n\n/** Whether invites are on for the title (off by default) — until they are, the lobby hides the tab. */\nexport function hasTitleData(\n section: { IsEnabled?: boolean | null } | null | undefined,\n): boolean {\n return section?.IsEnabled === true;\n}\n"
56
+ },
57
+ {
58
+ "path": "module.ts",
59
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeReferralScreen, makeReferralWatcher } from \"./ReferralScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes — on a title with invites on.\nexport const inviteModule: Module = defineModule({\n id: \"invite\",\n meta: { name: \"Invite\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n // Always in the lobby (owner's decision 29.09.2026); with invites off the screen says so.\n const enabled = hasTitleData(\n configSection<{ IsEnabled?: boolean | null }>(ctx.client, \"Referral\"),\n );\n ctx.features.register({\n id: \"invite\",\n label: t(\"title\"),\n icon: \"link\",\n group: \"social\",\n order: 90,\n Screen: makeReferralScreen(enabled),\n });\n // The badge reads the referral definitions — only where invites are on. On a title without them\n // that read was one more request in the startup burst, and the engine's per-player limit\n // answered it 429.\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeReferralWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"invite\" }),\n describeActions: {},\n });\n },\n});\n"
60
+ },
61
+ {
62
+ "path": "ReferralScreen.tsx",
63
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } 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 ResourceList,\n Spinner,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n plural,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current?.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n });\n }, [client]);\n useEffect(() => {\n if (defs.current)\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n }, [state?.Referral]);\n return null;\n };\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #2fb36b 0%, #146b3d 100%)\",\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>{t(\"joined\")}</div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\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.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\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 = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
64
+ },
65
+ {
66
+ "path": "module.meta.json",
67
+ "content": "{\n \"id\": \"invite\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.\",\n \"description\": \"A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature — always shown in the lobby; while the title has invites off (Referral.IsEnabled, off by default) the screen says so and makes no request. Its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.\",\n \"provides\": [\n \"own invite code and link\",\n \"share or copy the invite\",\n \"friends who came by your code\",\n \"invite reward ladder with claim all\",\n \"enter a friend's code popup\",\n \"invite badge\",\n \"demo referral rewards\"\n ],\n \"tags\": [\"referral\", \"invite\", \"friends\", \"growth\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
68
+ }
69
+ ]
70
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "leaderboard-ui",
2
+ "id": "leaderboard",
3
3
  "meta": {
4
4
  "name": "Leaderboards",
5
5
  "type": "app",
@@ -36,12 +36,12 @@
36
36
  "version": "0.1.0"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.19.0",
40
- "@idosgames/module-sdk": "0.5.0",
39
+ "@idosgames/core": "0.19.1",
40
+ "@idosgames/module-sdk": "0.7.0",
41
41
  "@idosgames/react": "0.3.0",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "8a49ee014e841fdc2e342f03b66fbd4172e2e09a07618cd72214b7ff8f0ecb57",
44
+ "contentHash": "9bc3e6c5600bcee5d20c1449b191702d4eb08a6febbb980a3cf5e397a3a826f5",
45
45
  "files": [
46
46
  {
47
47
  "path": "i18n.ts",
@@ -49,27 +49,23 @@
49
49
  },
50
50
  {
51
51
  "path": "index.ts",
52
- "content": "// @idosgames/mod-leaderboard-ui — Leaderboards screen for any game: several boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the previous cycle.\n\nexport { leaderboardUiModule } from \"./module\";\n"
52
+ "content": "// @idosgames/mod-leaderboard — Leaderboards screen for any game: several boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the previous cycle.\n\nexport { leaderboardModule } from \"./module\";\n"
53
53
  },
54
54
  {
55
55
  "path": "LeaderboardScreen.tsx",
56
56
  "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n configSection,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boardAction,\n cachedProgress,\n enabledBoards,\n milestoneRows,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat\n label={t(\"myRank\")}\n value={progress?.LastKnownRank ? `#${progress.LastKnownRank}` : \"—\"}\n />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={progress?.LastKnownRank ?? null}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n background: mine\n ? \"linear-gradient(180deg, #4b5ae0 0%, #2a2f86 100%)\"\n : panel.background,\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? \"rgba(0,0,0,.3)\",\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
57
57
  },
58
- {
59
- "path": "model.test.ts",
60
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n boardAction,\n cachedProgress,\n claimableBoards,\n enabledBoards,\n milestoneRows,\n parseRankRange,\n rewardForRank,\n type LeaderboardSection,\n} from \"./model\";\n\ndescribe(\"leaderboards\", () => {\n const section = seed.Leaderboard as unknown as LeaderboardSection;\n\n it(\"the seeded weekly board comes first; disabled boards are skipped\", () => {\n expect(enabledBoards(section)[0]?.LeaderboardID).toBe(\"weekly_score\");\n expect(\n enabledBoards({ Definitions: { b: { IsEnabled: false }, a: {} } }).map(\n (b) => b.LeaderboardID,\n ),\n ).toEqual([\"a\"]);\n expect(enabledBoards(undefined)).toEqual([]);\n });\n\n it(\"the seeded invites board asks to invite, the score board to play\", () => {\n const boards = enabledBoards(section);\n expect(boards.map((b) => b.LeaderboardID)).toEqual([\n \"weekly_score\",\n \"weekly_invites\",\n ]);\n expect(boardAction(boards[0])).toBe(\"play\");\n expect(boardAction(boards[1])).toBe(\"invite\");\n expect(\n boardAction({\n ScoreSources: [\n { SourceType: \"ReferralInvite\" },\n { SourceType: \"CustomAction\" },\n ],\n }),\n ).toBe(\"play\");\n });\n\n it(\"reads rank ranges\", () => {\n expect(parseRankRange(\"1\")).toEqual([1, 1]);\n expect(parseRankRange(\"2-5\")).toEqual([2, 5]);\n expect(parseRankRange(\"51+\")).toEqual([51, Number.POSITIVE_INFINITY]);\n expect(parseRankRange(\"top\")).toBeNull();\n });\n\n it(\"finds the reward of a rank in the seeded rank rewards\", () => {\n const board = enabledBoards(section)[0];\n expect(rewardForRank(board, 1).length).toBeGreaterThan(0);\n expect(rewardForRank(board, 0)).toEqual([]);\n expect(rewardForRank(board, 1_000_000)).toEqual([]);\n });\n\n it(\"milestones in order, with what is claimable\", () => {\n const def = {\n Milestones: {\n m2: { RequiredProgress: 500 },\n m1: { RequiredProgress: 100 },\n },\n };\n expect(milestoneRows(def, 150, []).map((m) => [m.id, m.state])).toEqual([\n [\"m1\", \"ready\"],\n [\"m2\", \"locked\"],\n ]);\n expect(milestoneRows(def, 600, [\"m1\"])[0]?.state).toBe(\"claimed\");\n });\n\n it(\"finds a board's claimed milestones under the cache's cycle-doc key\", () => {\n const state = {\n ProgressByCycle: { T123_weekly_score: { ClaimedMilestoneIDs: [\"m1\"] } },\n };\n expect(cachedProgress(state, \"weekly_score\").claimed).toEqual([\"m1\"]);\n expect(cachedProgress(state, \"other\").claimed).toEqual([]);\n });\n\n it(\"badge counts boards with something to claim\", () => {\n expect(\n claimableBoards([\n { LeaderboardID: \"a\", HasUnclaimedReward: true },\n { LeaderboardID: \"b\" },\n null,\n ]),\n ).toBe(1);\n });\n});\n"
61
- },
62
58
  {
63
59
  "path": "model.ts",
64
- "content": "// Pure helpers of the leaderboards screen — covered by model.test.ts.\n\nimport type {\n GetMyProgressResponse,\n ResourceGrant,\n UserLeaderboardsState,\n} from \"@idosgames/core\";\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface MilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n SortOrder?: number | null;\n}\n\nexport interface LeaderboardDefinitionView {\n LeaderboardID?: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null;\n IsEnabled?: boolean | null;\n RankRewards?: Array<{\n Rank?: string | null;\n Rewards?: ResourceGrant | null;\n } | null> | null;\n Milestones?: Record<string, MilestoneView | null> | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n}\n\n/**\n * What the board's call-to-action does. A board fed only by invites (`ReferralInvite` — the server\n * scores a new friend) asks the player to invite, not to play: playing would not move it.\n */\nexport function boardAction(\n def: LeaderboardDefinitionView | undefined,\n): \"invite\" | \"play\" {\n const sources = (def?.ScoreSources ?? []).filter((s) => s);\n return sources.length > 0 &&\n sources.every((s) => s?.SourceType === \"ReferralInvite\")\n ? \"invite\"\n : \"play\";\n}\n\nexport interface LeaderboardSection {\n Definitions?: Record<string, LeaderboardDefinitionView | null> | null;\n}\n\n/** Enabled boards, the preferred one first, then by id. */\nexport function enabledBoards(\n section: LeaderboardSection | undefined,\n preferred = \"weekly_score\",\n): Array<LeaderboardDefinitionView & { LeaderboardID: string }> {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (d): d is LeaderboardDefinitionView & { LeaderboardID: string } =>\n d !== null && d.IsEnabled !== false,\n )\n .sort((a, b) =>\n a.LeaderboardID === preferred\n ? -1\n : b.LeaderboardID === preferred\n ? 1\n : a.LeaderboardID.localeCompare(b.LeaderboardID),\n );\n}\n\n/** \"1\" → [1,1]; \"2-5\" → [2,5]; \"51+\" → [51, ∞]; junk → null. */\nexport function parseRankRange(\n range: string | null | undefined,\n): [number, number] | null {\n const s = (range ?? \"\").trim();\n let m = /^(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[1])];\n m = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[2])];\n m = /^(\\d+)\\s*\\+$/.exec(s);\n if (m) return [Number(m[1]), Number.POSITIVE_INFINITY];\n return null;\n}\n\n/** What a rank earns under the board's rank rewards. */\nexport function rewardForRank(\n def: LeaderboardDefinitionView | undefined,\n rank: number | null | undefined,\n): ResourceLine[] {\n if (!rank || rank < 1) return [];\n for (const r of def?.RankRewards ?? []) {\n const range = parseRankRange(r?.Rank);\n if (range && rank >= range[0] && rank <= range[1])\n return grantLines(r?.Rewards);\n }\n return [];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Score milestones of the current cycle, in order, with what is claimable. */\nexport function milestoneRows(\n def: LeaderboardDefinitionView | undefined,\n progress: number,\n claimed: readonly string[],\n): MilestoneRow[] {\n return Object.entries(def?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter(\n (x): x is { id: string; m: MilestoneView } =>\n x.m !== null && x.m !== undefined,\n )\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0) ||\n Number(a.m.SortOrder ?? 0) - Number(b.m.SortOrder ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : progress >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** The cached progress of one board (the cache keys it by an internal cycle-doc id). */\nexport function cachedProgress(\n state: UserLeaderboardsState | null | undefined,\n leaderboardID: string,\n): { claimed: string[] } {\n for (const [key, p] of Object.entries(state?.ProgressByCycle ?? {})) {\n if (\n key === leaderboardID ||\n key.endsWith(`_${leaderboardID}`) ||\n key.includes(`_${leaderboardID}:`)\n )\n return { claimed: p.ClaimedMilestoneIDs ?? [] };\n }\n return { claimed: [] };\n}\n\n/** Badge: boards with a rank reward or a milestone waiting. */\nexport function claimableBoards(\n progress: ReadonlyArray<GetMyProgressResponse | null | undefined>,\n): number {\n return progress.filter(\n (p) =>\n p?.HasUnclaimedReward ||\n p?.HasUnclaimedMilestone ||\n (p?.ClaimableGraceCycles?.length ?? 0) > 0,\n ).length;\n}\n"
60
+ "content": "// Pure helpers of the leaderboards screen — covered by model.test.ts.\n\nimport type {\n GetMyProgressResponse,\n ResourceGrant,\n UserLeaderboardsState,\n} from \"@idosgames/core\";\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface MilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n SortOrder?: number | null;\n}\n\nexport interface LeaderboardDefinitionView {\n LeaderboardID?: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null;\n IsEnabled?: boolean | null;\n RankRewards?: Array<{\n Rank?: string | null;\n Rewards?: ResourceGrant | null;\n } | null> | null;\n Milestones?: Record<string, MilestoneView | null> | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n}\n\n/**\n * What the board's call-to-action does. A board fed only by invites (`ReferralInvite` — the server\n * scores a new friend) asks the player to invite, not to play: playing would not move it.\n */\nexport function boardAction(\n def: LeaderboardDefinitionView | undefined,\n): \"invite\" | \"play\" {\n const sources = (def?.ScoreSources ?? []).filter((s) => s);\n return sources.length > 0 &&\n sources.every((s) => s?.SourceType === \"ReferralInvite\")\n ? \"invite\"\n : \"play\";\n}\n\nexport interface LeaderboardSection {\n Definitions?: Record<string, LeaderboardDefinitionView | null> | null;\n}\n\n/** Enabled boards, the preferred one first, then by id. */\nexport function enabledBoards(\n section: LeaderboardSection | undefined,\n preferred = \"weekly_score\",\n): Array<LeaderboardDefinitionView & { LeaderboardID: string }> {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (d): d is LeaderboardDefinitionView & { LeaderboardID: string } =>\n d !== null && d.IsEnabled !== false,\n )\n .sort((a, b) =>\n a.LeaderboardID === preferred\n ? -1\n : b.LeaderboardID === preferred\n ? 1\n : a.LeaderboardID.localeCompare(b.LeaderboardID),\n );\n}\n\n/** \"1\" → [1,1]; \"2-5\" → [2,5]; \"51+\" → [51, ∞]; junk → null. */\nexport function parseRankRange(\n range: string | null | undefined,\n): [number, number] | null {\n const s = (range ?? \"\").trim();\n let m = /^(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[1])];\n m = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[2])];\n m = /^(\\d+)\\s*\\+$/.exec(s);\n if (m) return [Number(m[1]), Number.POSITIVE_INFINITY];\n return null;\n}\n\n/** What a rank earns under the board's rank rewards. */\nexport function rewardForRank(\n def: LeaderboardDefinitionView | undefined,\n rank: number | null | undefined,\n): ResourceLine[] {\n if (!rank || rank < 1) return [];\n for (const r of def?.RankRewards ?? []) {\n const range = parseRankRange(r?.Rank);\n if (range && rank >= range[0] && rank <= range[1])\n return grantLines(r?.Rewards);\n }\n return [];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Score milestones of the current cycle, in order, with what is claimable. */\nexport function milestoneRows(\n def: LeaderboardDefinitionView | undefined,\n progress: number,\n claimed: readonly string[],\n): MilestoneRow[] {\n return Object.entries(def?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter(\n (x): x is { id: string; m: MilestoneView } =>\n x.m !== null && x.m !== undefined,\n )\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0) ||\n Number(a.m.SortOrder ?? 0) - Number(b.m.SortOrder ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : progress >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** The cached progress of one board (the cache keys it by an internal cycle-doc id). */\nexport function cachedProgress(\n state: UserLeaderboardsState | null | undefined,\n leaderboardID: string,\n): { claimed: string[] } {\n for (const [key, p] of Object.entries(state?.ProgressByCycle ?? {})) {\n if (\n key === leaderboardID ||\n key.endsWith(`_${leaderboardID}`) ||\n key.includes(`_${leaderboardID}:`)\n )\n return { claimed: p.ClaimedMilestoneIDs ?? [] };\n }\n return { claimed: [] };\n}\n\n/** Badge: boards with a rank reward or a milestone waiting. */\nexport function claimableBoards(\n progress: ReadonlyArray<GetMyProgressResponse | null | undefined>,\n): number {\n return progress.filter(\n (p) =>\n p?.HasUnclaimedReward ||\n p?.HasUnclaimedMilestone ||\n (p?.ClaimableGraceCycles?.length ?? 0) > 0,\n ).length;\n}\n\n/** Whether the title has an enabled leaderboard — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LeaderboardSection | undefined): boolean {\n return Object.values(section?.Definitions ?? {}).some(\n (d) =>\n d != null && (d as { IsEnabled?: boolean | null }).IsEnabled !== false,\n );\n}\n"
65
61
  },
66
62
  {
67
63
  "path": "module.ts",
68
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n makeCycleRewardWatcher,\n makeLeaderboardScreen,\n} from \"./LeaderboardScreen\";\nimport { t } from \"./i18n\";\n\n// Leaderboards as a feature of the game. The always-on watcher pops up the reward of a finished\n// round and keeps the badge (a rank reward or a milestone waiting).\nexport const leaderboardUiModule: Module = defineModule({\n id: \"leaderboard-ui\",\n meta: { name: \"Leaderboards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"leaderboard\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 30,\n primary: true,\n Screen: makeLeaderboardScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"cycle-reward\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCycleRewardWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"leaderboard\" }),\n describeActions: {},\n });\n },\n});\n"
64
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport {\n makeCycleRewardWatcher,\n makeLeaderboardScreen,\n} from \"./LeaderboardScreen\";\nimport { hasTitleData, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards as a feature of the game. The always-on watcher pops up the reward of a finished\n// round and keeps the badge (a rank reward or a milestone waiting).\nexport const leaderboardModule: Module = defineModule({\n id: \"leaderboard\",\n meta: { name: \"Leaderboards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"leaderboard\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 30,\n primary: true,\n available: false,\n Screen: makeLeaderboardScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"leaderboard\",\n hasTitleData(\n configSection<LeaderboardSection>(ctx.client, \"Leaderboard\"),\n ),\n \"no enabled leaderboard on the title\",\n );\n ctx.registerPanel({\n id: \"cycle-reward\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCycleRewardWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"leaderboard\" }),\n describeActions: {},\n });\n },\n});\n"
69
65
  },
70
66
  {
71
67
  "path": "module.meta.json",
72
- "content": "{\n \"id\": \"leaderboard-ui\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
68
+ "content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
73
69
  }
74
70
  ]
75
71
  }
@@ -0,0 +1,67 @@
1
+ {
2
+ "id": "lootboxes",
3
+ "meta": {
4
+ "name": "Chests",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "Chests for any game: the title's lootboxes with every way to open them, once or ten at a time, with an opening animation.",
11
+ "description": "A ready chests screen (Unity LootboxOpeningPopUp) for ANY game, built on the @idosgames/react/ui kit and client.lootbox. Every lootbox of the title as a card for each way to open it (currencies, a chest item, a key); 'Open' once, and 'Open ×10' where the lootbox's ceiling (MaxOpenCount) and the player's balance allow — the box shakes while the server rolls it, bursts, and the rewards fly out. The inventory hands a chest or key item over (ctx.features.open('lootboxes', { itemID })) — the screen then shows what that item opens. Registers the 'lootboxes' feature; its badge counts chests the player can open with the items they hold.",
12
+ "provides": [
13
+ "lootbox list with every way to open",
14
+ "opening animation with rewards",
15
+ "open ten at once",
16
+ "open a chest with a key item",
17
+ "badge for openable chests"
18
+ ],
19
+ "tags": [
20
+ "lootbox",
21
+ "chest",
22
+ "gacha",
23
+ "rewards",
24
+ "ui",
25
+ "screen",
26
+ "economy"
27
+ ],
28
+ "author": {
29
+ "name": "iDos Games",
30
+ "url": "https://idosgames.com"
31
+ },
32
+ "version": "0.1.0"
33
+ },
34
+ "dependencies": {
35
+ "@idosgames/core": "0.19.1",
36
+ "@idosgames/module-sdk": "0.7.0",
37
+ "@idosgames/react": "0.3.0",
38
+ "react": "19.2.7"
39
+ },
40
+ "contentHash": "64fb8cc7fb26c68c838117f01739d5bafcc8da16d797691c651658f1911f527e",
41
+ "files": [
42
+ {
43
+ "path": "i18n.ts",
44
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Chests\",\n open: \"Open\",\n openTimes: \"Open\",\n opening: \"Opening…\",\n noChests: \"No chests in this game yet. Add them in LiveOps → Lootboxes.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Сундуки\",\n open: \"Открыть\",\n openTimes: \"Открыть\",\n opening: \"Открываем…\",\n noChests: \"Сундуков пока нет. Добавьте их в LiveOps → Lootboxes.\",\n});\n"
45
+ },
46
+ {
47
+ "path": "index.ts",
48
+ "content": "// @idosgames/mod-lootboxes — Chests for any game: the title's lootboxes with every way to open them, opening once or ten times with an animation.\n\nexport { lootboxesModule } from \"./module\";\n"
49
+ },
50
+ {
51
+ "path": "LootboxesScreen.tsx",
52
+ "content": "import { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n type LootboxSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection<LootboxSection>(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(o.lootboxID)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/** The box shakes while the server rolls it, bursts open, and the rewards pop out. */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const openBox = async (close: () => void) => {\n setBusy(true);\n const started = Date.now();\n const res = await client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 700 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n setBusy(false);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n close();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={\n count > 1\n ? `${catalog.localize(option.lootboxID)} ×${count}`\n : catalog.localize(option.lootboxID)\n }\n onClose={onClose}\n variant=\"center\"\n >\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 12,\n padding: \"10px 0\",\n }}\n >\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={120} />\n </div>\n <ResourceList\n lines={option.price.map((l) => ({ ...l, amount: l.amount * count }))}\n />\n </div>\n <OpenButton busy={busy} onOpen={openBox} />\n </Popup>\n );\n}\n\nfunction OpenButton({\n busy,\n onOpen,\n}: {\n busy: boolean;\n onOpen: (close: () => void) => Promise<void>;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void onOpen(close)}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n );\n}\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function makeLootboxesWatcher(features: FeatureRegistry) {\n return function LootboxesBadge(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection<LootboxSection>(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return null;\n };\n}\n"
53
+ },
54
+ {
55
+ "path": "model.ts",
56
+ "content": "// Pure helpers of the chests screen — covered by model.test.ts.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface LootboxView {\n LootboxID?: string;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Name?: string | null; Cost?: unknown } | null\n > | null;\n MaxOpenCount?: number | null;\n}\n\n/** The title's Lootbox section, as far as the chests screen cares. */\nexport interface LootboxSection {\n Definitions?: Record<string, LootboxView | null> | null;\n Settings?: { MaxOpenCount?: number | null } | null;\n}\n\n/** What the player holds, as far as paying for a chest goes. */\nexport interface InventoryView {\n Items?: Record<\n string,\n { StackableAmount?: number; TotalAmount?: number } | null\n > | null;\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n}\n\nexport interface LootboxOption {\n lootboxID: string;\n optionID: string;\n name: string | null;\n icon: string;\n price: ResourceLine[];\n /** Most boxes one open() may take (the server clamps a bigger count to it). */\n maxOpen: number;\n}\n\n/** The platform's ceiling when neither the lootbox nor the module sets one. */\nconst DEFAULT_MAX_OPEN = 100;\n\n/** Every way to open every lootbox of the title (one entry per price option). */\nexport function lootboxOptions(\n section: LootboxSection | null | undefined,\n): LootboxOption[] {\n const out: LootboxOption[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const maxOpen = Number(\n def.MaxOpenCount ?? section?.Settings?.MaxOpenCount ?? DEFAULT_MAX_OPEN,\n );\n if (maxOpen <= 0) continue;\n for (const [optionID, option] of Object.entries(def.PriceOptions ?? {})) {\n if (!option) continue;\n out.push({\n lootboxID: def.LootboxID ?? id,\n optionID: option.OptionID || optionID,\n name: option.Name ?? null,\n icon: def.AssetPaths?.icon ?? \"chest\",\n price: costLines(option.Cost as never),\n maxOpen,\n });\n }\n }\n return out.sort(\n (a, b) =>\n a.lootboxID.localeCompare(b.lootboxID) ||\n a.optionID.localeCompare(b.optionID),\n );\n}\n\n/** Lootboxes an item opens: options whose price consumes that item (a chest or a key). */\nexport function lootboxesOpenedBy(\n itemID: string,\n options: LootboxOption[],\n): LootboxOption[] {\n return options.filter((o) =>\n o.price.some((line) => line.kind === \"item\" && line.id === itemID),\n );\n}\n\n/** Whether the player holds what `times` openings of an option cost (currencies and items). */\nexport function canPay(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n times = 1,\n): boolean {\n return option.price.every((line) => {\n const need = line.amount * times;\n if (line.kind === \"currency\")\n return (\n Number(inventory?.VirtualCurrencies?.[line.id]?.Amount ?? 0) >= need\n );\n if (line.kind === \"item\")\n return (\n Number(\n inventory?.Items?.[line.id]?.TotalAmount ??\n inventory?.Items?.[line.id]?.StackableAmount ??\n 0,\n ) >= need\n );\n return true;\n });\n}\n\n/** The bulk opening offered next to a single one: ten boxes, when the ceiling and the wallet allow. */\nexport const BULK = 10;\nexport function canOpenBulk(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n): boolean {\n return option.maxOpen >= BULK && canPay(option, inventory, BULK);\n}\n\n/** Badge: lootboxes the player can open right now with items (chests, keys) — not with currency. */\nexport function openableWithItems(\n options: LootboxOption[],\n inventory: InventoryView | null | undefined,\n): number {\n return options.filter(\n (o) => o.price.some((l) => l.kind === \"item\") && canPay(o, inventory),\n ).length;\n}\n\n/** Whether the title has lootboxes — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LootboxSection | undefined): boolean {\n return lootboxOptions(section).length > 0;\n}\n"
57
+ },
58
+ {
59
+ "path": "module.ts",
60
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { LootboxesScreen, makeLootboxesWatcher } from \"./LootboxesScreen\";\nimport { hasTitleData, type LootboxSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests as a feature of the game. The inventory hands a chest or key item over here\n// (features.open(\"lootboxes\", { itemID })). Badge: chests the player can open right now with the\n// items they hold — read from the cached inventory, no request of its own.\nexport const lootboxesModule: Module = defineModule({\n id: \"lootboxes\",\n meta: { name: \"Chests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"lootboxes\",\n label: t(\"title\"),\n icon: \"chest\",\n group: \"economy\",\n order: 55,\n available: false,\n Screen: LootboxesScreen,\n });\n const enabled = hasTitleData(\n configSection<LootboxSection>(ctx.client, \"Lootbox\"),\n );\n ctx.features.setAvailable(\n \"lootboxes\",\n enabled,\n \"no lootboxes on the title (Lootbox)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeLootboxesWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"lootboxes\" }),\n describeActions: {},\n });\n },\n});\n"
61
+ },
62
+ {
63
+ "path": "module.meta.json",
64
+ "content": "{\n \"id\": \"lootboxes\",\n \"type\": \"feature\",\n \"summary\": \"Chests for any game: the title's lootboxes with every way to open them, once or ten at a time, with an opening animation.\",\n \"description\": \"A ready chests screen (Unity LootboxOpeningPopUp) for ANY game, built on the @idosgames/react/ui kit and client.lootbox. Every lootbox of the title as a card for each way to open it (currencies, a chest item, a key); 'Open' once, and 'Open ×10' where the lootbox's ceiling (MaxOpenCount) and the player's balance allow — the box shakes while the server rolls it, bursts, and the rewards fly out. The inventory hands a chest or key item over (ctx.features.open('lootboxes', { itemID })) — the screen then shows what that item opens. Registers the 'lootboxes' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"lootbox list with every way to open\",\n \"opening animation with rewards\",\n \"open ten at once\",\n \"open a chest with a key item\",\n \"badge for openable chests\"\n ],\n \"tags\": [\"lootbox\", \"chest\", \"gacha\", \"rewards\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
65
+ }
66
+ ]
67
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "id": "mailbox",
3
+ "meta": {
4
+ "name": "Mailbox",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Mailbox for any game: letters with rewards, publisher broadcasts, gifts from friends, resource transfers and missed rewards of other systems — with 'Claim all'.",
12
+ "description": "A ready mailbox screen for ANY game, built on the @idosgames/react/ui kit and client.mailbox. Tabs by category (all, rewards, gifts, system); a letter opens with its text in the player's language, the sender, a banner, the rewards and a button to the screen or page the letter links to. 'Claim' per letter and 'Claim all' in one server operation; rewards of other systems the player has not taken yet (leaderboard cycle, team event prize, invite rewards) are shown on top and claimed right here. 'Send gifts': a free daily gift to friends ('to everyone' in one tap) and a transfer of own coins or items to one friend — declined or expired transfers come back. 'Delete read' cleans up. The lobby badge counts unread letters and rewards waiting. The server owns everything: limits, friendship, expiry, what can be transferred. Opens straight on a friend with ctx.features.open('mailbox', { friendUserID, mode: 'gift' | 'transfer' }) — the friends system does that from a friend's card. Registers the 'mailbox' feature, hidden until the title turns the mailbox on. Ships title-seed.json: the mailbox on, a welcome template, a daily coin gift and coin/potion transfers.",
13
+ "provides": [
14
+ "mailbox screen",
15
+ "letters with rewards and claim all",
16
+ "publisher broadcasts in the inbox",
17
+ "gifts to friends",
18
+ "resource transfers between friends",
19
+ "missed rewards of other systems",
20
+ "mailbox badge"
21
+ ],
22
+ "tags": [
23
+ "mailbox",
24
+ "mail",
25
+ "inbox",
26
+ "gifts",
27
+ "rewards",
28
+ "social",
29
+ "ui",
30
+ "screen"
31
+ ],
32
+ "author": {
33
+ "name": "iDos Games",
34
+ "url": "https://idosgames.com"
35
+ },
36
+ "version": "0.1.0"
37
+ },
38
+ "dependencies": {
39
+ "@idosgames/core": "0.19.1",
40
+ "@idosgames/module-sdk": "0.7.0",
41
+ "@idosgames/react": "0.3.0",
42
+ "react": "19.2.7"
43
+ },
44
+ "contentHash": "b5f960cc3809c920a30dfe655c0531fea691236f418d59107daaffa8a9bbcb66",
45
+ "files": [
46
+ {
47
+ "path": "i18n.ts",
48
+ "content": "import { makeT } from \"@idosgames/react/ui\";\nimport type { LetterText } from \"./model\";\n\nconst EN = {\n title: \"Mail\",\n disabled: \"The mailbox is turned off in this game.\",\n tabAll: \"All\",\n tabReward: \"Rewards\",\n tabGift: \"Gifts\",\n tabSystem: \"News\",\n empty: \"No letters yet.\",\n emptyTab: \"Nothing here.\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n expired: \"Expired\",\n expiresIn: \"Expires in\",\n more: \"Show more\",\n deleteRead: \"Delete read\",\n deleteReadAsk:\n \"Delete every read letter? Letters with unclaimed rewards stay.\",\n deleted: \"Deleted\",\n delete: \"Delete\",\n decline: \"Send back\",\n declineAsk: \"Send the resources back to your friend?\",\n declined: \"Sent back\",\n open: \"Open\",\n from: \"From\",\n note: \"Message\",\n waiting: \"Waiting for you\",\n sendGifts: \"Send gifts\",\n gift: \"Gift\",\n transfer: \"Transfer\",\n giftHint:\n \"A gift your friend claims in their mailbox. Once per period for each friend.\",\n transferHint:\n \"Send your own resources. If your friend does not take them in time, they come back.\",\n toEveryone: \"Send to all\",\n sendTo: \"Send\",\n sentToday: \"Sent today\",\n pricePerFriend: \"Price per friend\",\n leftInPeriod: \"Left\",\n resetsIn: \"Resets in\",\n commission:\n \"Commission {p}% — your friend gets less (returned if they decline)\",\n fixedFee: \"Fee\",\n friendGets: \"Your friend gets\",\n noFriends: \"Add friends to send them gifts.\",\n pickFriend: \"Choose a friend\",\n owned: \"You have\",\n amount: \"Amount\",\n notePlaceholder: \"A few words (optional)\",\n transferAsk: \"Send to {name}?\",\n giftSent: \"Gift sent\",\n transferSent: \"Sent! It waits in your friend's mailbox.\",\n nothingToSend: \"Choose what to send.\",\n someFailed: \"Some could not be sent\",\n // Server codes → words.\n errALREADY_CLAIMED: \"Already claimed.\",\n errEXPIRED: \"This letter has expired.\",\n errMESSAGE_NOT_FOUND: \"This letter is gone.\",\n errNOT_FRIENDS: \"You are not friends.\",\n errBLOCKED: \"This player does not accept gifts from you.\",\n errALREADY_SENT: \"Already sent this period.\",\n errSEND_LIMIT: \"The limit for this period is reached. Come back later.\",\n errNOT_ENOUGH_RESOURCES: \"Not enough resources.\",\n errAMOUNT_TOO_SMALL: \"After the commission your friend would get nothing.\",\n errRECIPIENT_LIMIT: \"Your friend got enough gifts today.\",\n errFRIENDSHIP_TOO_NEW: \"You can send resources to a friend a bit later.\",\n errAMOUNT_TOO_LARGE: \"Too much for one transfer.\",\n errRESOURCE_NOT_GIFTABLE: \"This can not be sent.\",\n errTRANSFER_DISABLED: \"Transfers are turned off.\",\n errGIFT_NOT_ALLOWED: \"This gift is not available to you.\",\n errMODULE_REWARD_UNAVAILABLE: \"This reward is no longer available.\",\n errMAILBOX_DISABLED: \"The mailbox is turned off in this game.\",\n // The platform's own letters (templates \"system.*\"), unless the publisher rewrote them.\n sysGiftSubject: \"A gift from {sender}\",\n sysGiftBody: \"Your friend {sender} sent you a gift.\",\n sysTransferSubject: \"{sender} sent you resources\",\n sysTransferBody: \"Claim them — or send them back.\",\n sysTransferReturnSubject: \"Your transfer came back\",\n sysTransferReturnBody:\n \"Your friend did not take it in time — here it is again.\",\n sysLeaderboardSubject: \"Leaderboard reward\",\n sysLeaderboardBody: \"You finished #{rank} in the leaderboard.\",\n sysCoopGrandSubject: \"Team event prize\",\n sysCoopGrandBody: \"Your team finished the event. Here is the grand prize.\",\n sysCoopObjectSubject: \"Team event reward\",\n sysCoopObjectBody: \"Your share of the team's progress.\",\n sysSeasonSubject: \"Season rewards you missed\",\n sysSeasonBody:\n \"The season is over — we kept the rewards you reached (tier {tier}).\",\n sysReferralSubject: \"Reward for friends\",\n sysReferralBody: \"{friends} friends joined by your code.\",\n} as const;\n\ntype Key = keyof typeof EN;\n\nexport const t = makeT<Key>(EN, {\n title: \"Почта\",\n disabled: \"Почта в этой игре выключена.\",\n tabAll: \"Все\",\n tabReward: \"Награды\",\n tabGift: \"Подарки\",\n tabSystem: \"Новости\",\n empty: \"Писем пока нет.\",\n emptyTab: \"Здесь пусто.\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n expired: \"Истекло\",\n expiresIn: \"Истекает через\",\n more: \"Показать ещё\",\n deleteRead: \"Удалить прочитанные\",\n deleteReadAsk:\n \"Удалить все прочитанные письма? Письма с незабранными наградами останутся.\",\n deleted: \"Удалено\",\n delete: \"Удалить\",\n decline: \"Вернуть\",\n declineAsk: \"Вернуть ресурсы другу?\",\n declined: \"Возвращено\",\n open: \"Перейти\",\n from: \"От\",\n note: \"Сообщение\",\n waiting: \"Ждут вас\",\n sendGifts: \"Подарки друзьям\",\n gift: \"Подарок\",\n transfer: \"Передать\",\n giftHint: \"Подарок — друг заберёт его в почте. Раз за период каждому другу.\",\n transferHint:\n \"Отправьте свои ресурсы. Если друг не заберёт их вовремя, они вернутся.\",\n toEveryone: \"Подарить всем\",\n sendTo: \"Отправить\",\n sentToday: \"Сегодня отправлено\",\n pricePerFriend: \"Цена за друга\",\n leftInPeriod: \"Осталось\",\n resetsIn: \"Обновится через\",\n commission:\n \"Комиссия {p}% — друг получит меньше (вернётся, если он откажется)\",\n fixedFee: \"Плата\",\n friendGets: \"Друг получит\",\n noFriends: \"Добавьте друзей, чтобы дарить им подарки.\",\n pickFriend: \"Выберите друга\",\n owned: \"У вас\",\n amount: \"Сколько\",\n notePlaceholder: \"Пара слов (необязательно)\",\n transferAsk: \"Отправить игроку {name}?\",\n giftSent: \"Подарок отправлен\",\n transferSent: \"Отправлено! Ждёт друга в почте.\",\n nothingToSend: \"Выберите, что отправить.\",\n someFailed: \"Часть не отправилась\",\n errALREADY_CLAIMED: \"Уже получено.\",\n errEXPIRED: \"Срок письма истёк.\",\n errMESSAGE_NOT_FOUND: \"Этого письма уже нет.\",\n errNOT_FRIENDS: \"Вы не друзья.\",\n errBLOCKED: \"Этот игрок не принимает от вас подарки.\",\n errALREADY_SENT: \"В этом периоде уже отправлено.\",\n errSEND_LIMIT: \"Лимит на этот период исчерпан. Возвращайтесь позже.\",\n errNOT_ENOUGH_RESOURCES: \"Не хватает ресурсов.\",\n errAMOUNT_TOO_SMALL: \"После комиссии другу ничего не останется.\",\n errRECIPIENT_LIMIT: \"Друг уже получил сегодня достаточно подарков.\",\n errFRIENDSHIP_TOO_NEW:\n \"Передавать ресурсы этому другу можно будет чуть позже.\",\n errAMOUNT_TOO_LARGE: \"Слишком много для одной передачи.\",\n errRESOURCE_NOT_GIFTABLE: \"Это нельзя передать.\",\n errTRANSFER_DISABLED: \"Передача выключена.\",\n errGIFT_NOT_ALLOWED: \"Этот подарок вам недоступен.\",\n errMODULE_REWARD_UNAVAILABLE: \"Эта награда больше недоступна.\",\n errMAILBOX_DISABLED: \"Почта в этой игре выключена.\",\n sysGiftSubject: \"Подарок от {sender}\",\n sysGiftBody: \"Ваш друг {sender} прислал вам подарок.\",\n sysTransferSubject: \"{sender} прислал вам ресурсы\",\n sysTransferBody: \"Заберите их — или верните отправителю.\",\n sysTransferReturnSubject: \"Передача вернулась\",\n sysTransferReturnBody: \"Друг не забрал её вовремя — ресурсы снова у вас.\",\n sysLeaderboardSubject: \"Награда таблицы лидеров\",\n sysLeaderboardBody: \"Вы заняли {rank}-е место в таблице лидеров.\",\n sysCoopGrandSubject: \"Приз командного ивента\",\n sysCoopGrandBody: \"Ваша команда прошла ивент. Вот главный приз.\",\n sysCoopObjectSubject: \"Награда командного ивента\",\n sysCoopObjectBody: \"Ваша доля за прогресс команды.\",\n sysSeasonSubject: \"Незабранные награды сезона\",\n sysSeasonBody:\n \"Сезон закончился — мы сохранили награды, до которых вы дошли (ступень {tier}).\",\n sysReferralSubject: \"Награда за друзей\",\n sysReferralBody: \"По вашему коду пришло друзей: {friends}.\",\n});\n\nconst BUILT_IN: Record<string, [Key, Key]> = {\n \"system.gift\": [\"sysGiftSubject\", \"sysGiftBody\"],\n \"system.transfer\": [\"sysTransferSubject\", \"sysTransferBody\"],\n \"system.transfer_return\": [\n \"sysTransferReturnSubject\",\n \"sysTransferReturnBody\",\n ],\n \"system.leaderboard_reward\": [\"sysLeaderboardSubject\", \"sysLeaderboardBody\"],\n \"system.coop_grand_prize\": [\"sysCoopGrandSubject\", \"sysCoopGrandBody\"],\n \"system.coop_object_reward\": [\"sysCoopObjectSubject\", \"sysCoopObjectBody\"],\n \"system.season_unclaimed\": [\"sysSeasonSubject\", \"sysSeasonBody\"],\n \"system.referral_reward\": [\"sysReferralSubject\", \"sysReferralBody\"],\n};\n\n/** Texts of the platform's own templates (a letter of a publisher's template carries its own). */\nexport function builtInText(templateID: string): LetterText | null {\n const keys = BUILT_IN[templateID];\n return keys ? { subject: t(keys[0]), body: t(keys[1]) } : null;\n}\n\n/** A server code in the player's words; any other error as the kit shows it. */\nexport function mailError(error: string | null | undefined): string | null {\n const code = (error ?? \"\").trim();\n const key = `err${code}` as Key;\n return code && key in EN ? t(key) : null;\n}\n"
49
+ },
50
+ {
51
+ "path": "index.ts",
52
+ "content": "// @idosgames/mod-mailbox — Mailbox for any game: letters with rewards, broadcasts, gifts and transfers between friends, missed rewards and claim all.\n\nexport { mailboxModule } from \"./module\";\n"
53
+ },
54
+ {
55
+ "path": "MailboxScreen.tsx",
56
+ "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n MailboxCategory,\n MailboxDefinitions,\n MailboxGiftStatusResponse,\n MailboxLetter,\n MailboxPendingReward,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n ResourceBadge,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n bundleLines,\n isGlyph,\n outlined,\n pickLanguage,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport {\n afterFee,\n claimableCount,\n expiresSoon,\n feePercent,\n letterState,\n letterText,\n mailBadge,\n mergePages,\n newRequestID,\n transferEntries,\n transferOptions,\n type TransferInventory,\n type TransferOption,\n} from \"./model\";\nimport { builtInText, mailError, t } from \"./i18n\";\n\n// Mailbox (Unity V2/Runtime/UI/Mailbox): letters newest first by category, a letter opened in a\n// popup, \"Claim\" / \"Claim all\", missed rewards of other systems on top, and \"Send gifts\" — a free\n// daily gift to friends and a transfer of own resources. Everything the server decides (expiry,\n// limits, friendship) comes back as its codes; the screen only words them.\n\ntype Tab = \"all\" | \"Reward\" | \"Gift\" | \"System\";\n\n/** What `ctx.features.open(\"mailbox\", args)` understands — the friends system sends it. */\ninterface MailboxArgs {\n friendUserID?: string;\n friendName?: string;\n mode?: \"gift\" | \"transfer\";\n}\n\ninterface Friend {\n UserID: string;\n PublicData?: { Username?: string | null; AvatarUrl?: string | null } | null;\n}\n\nfunction words(error: string | null | undefined): string {\n return mailError(error) ?? errorText(error);\n}\n\nfunction friendName(f: Friend): string {\n const name = f.PublicData?.Username?.trim();\n if (name) return name;\n return f.UserID.length > 10\n ? `${f.UserID.slice(0, 5)}…${f.UserID.slice(-4)}`\n : f.UserID;\n}\n\nconst CATEGORY_GLYPH: Record<MailboxCategory, Glyph> = {\n System: \"bell\",\n Reward: \"chest\",\n Gift: \"gift\",\n Social: \"friends\",\n};\n\nconst MODULE_GLYPH: Record<string, Glyph> = {\n Leaderboard: \"trophy\",\n CoopEvent: \"swords\",\n Season: \"crown\",\n Referral: \"link\",\n};\n\n/** Badge on login and on coming back to the tab; mailbox actions refresh it themselves. */\nexport function makeMailboxWatcher(features: FeatureRegistry) {\n return function MailboxWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n const off = client.on(\"mailbox:countersChanged\", (c) =>\n features.setBadge(\"mailbox\", mailBadge(c)),\n );\n void client.mailbox.getUnreadCount();\n const onVisible = () => {\n if (document.visibilityState === \"visible\")\n void client.mailbox.getUnreadCount();\n };\n document.addEventListener(\"visibilitychange\", onVisible);\n return () => {\n off();\n document.removeEventListener(\"visibilitychange\", onVisible);\n };\n }, [client]);\n return null;\n };\n}\n\nexport function makeMailboxScreen(features: FeatureRegistry) {\n return function MailboxScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const defs = configSection<MailboxDefinitions>(client, \"Mailbox\");\n if (defs?.Enabled !== true)\n return <EmptyState glyph=\"gift\" text={t(\"disabled\")} />;\n return (\n <Mailbox\n features={features}\n defs={defs}\n args={(args ?? {}) as MailboxArgs}\n />\n );\n };\n}\n\nfunction Mailbox({\n features,\n defs,\n args,\n}: {\n features: FeatureRegistry;\n defs: MailboxDefinitions;\n args: MailboxArgs;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const now = useNow(30_000);\n const [tab, setTab] = useState<Tab>(\"all\");\n const [letters, setLetters] = useState<MailboxLetter[] | null>(null);\n const [pending, setPending] = useState<MailboxPendingReward[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [loadingMore, setLoadingMore] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const [opened, setOpened] = useState<MailboxLetter | null>(null);\n const [askClean, setAskClean] = useState(false);\n const [gifts, setGifts] = useState<MailboxArgs | null>(\n args.friendUserID || args.mode ? args : null,\n );\n\n const category = tab === \"all\" ? undefined : tab;\n // Letters in the language the screen speaks: the game's own locale when it has localization\n // tables, else the kit's language — otherwise a Russian screen showed English letters.\n const language = client.localization.locale ?? pickLanguage();\n const load = useCallback(async () => {\n const res = await client.mailbox.getMessages({ category, language });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n setLetters([]);\n return;\n }\n setLetters(res.data.Messages);\n setPending(res.data.PendingRewards ?? []);\n setCursor(res.data.NextCursor ?? null);\n }, [client, category, language]);\n useEffect(() => {\n setLetters(null);\n void load();\n }, [load]);\n\n const more = async () => {\n if (!cursor) return;\n setLoadingMore(true);\n const res = await client.mailbox.getMessages({\n category,\n cursor,\n language,\n });\n setLoadingMore(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n setLetters((shown) => mergePages(shown ?? [], res.data.Messages));\n setCursor(res.data.NextCursor ?? null);\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.mailbox.claimAll();\n setBusyAll(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n const got = grantedBy(res.data);\n if (got.length > 0) celebrate(got);\n if (res.data.Failed.length > 0)\n toast(words(res.data.Failed[0]?.Error), \"error\");\n await load();\n };\n\n const cleanRead = async () => {\n const res = await client.mailbox.deleteMessages(\"allRead\");\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(t(\"deleted\"));\n await load();\n };\n\n const giftsOn =\n Object.values(defs.Gifts?.Social ?? {}).some(\n (g) => g && g.Enabled !== false,\n ) || defs.Gifts?.Transfer?.Enabled === true;\n const shownPending = tab === \"all\" || tab === \"Reward\" ? pending : [];\n const toClaim = letters ? claimableCount(letters, shownPending, now) : 0;\n const hasRead = (letters ?? []).some(\n (l) => l.ReadAt && letterState(l, now) !== \"claimable\",\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <Tabs<Tab>\n tabs={[\n { id: \"all\", label: t(\"tabAll\") },\n { id: \"Reward\", label: t(\"tabReward\"), glyph: \"chest\" },\n { id: \"Gift\", label: t(\"tabGift\"), glyph: \"gift\" },\n { id: \"System\", label: t(\"tabSystem\"), glyph: \"bell\" },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n {toClaim > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n style={{ flex: 1 }}\n >\n {t(\"claimAll\")} · {toClaim}\n </Button>\n ) : null}\n {giftsOn ? (\n <Button\n tone=\"gold\"\n onClick={() => setGifts({})}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"mailbox:gifts\"\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"sendGifts\")}\n </Button>\n ) : null}\n </div>\n\n {letters === null ? (\n <Center>\n <Spinner />\n </Center>\n ) : (\n <>\n {shownPending.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"waiting\")}\n </div>\n ) : null}\n {shownPending.map((p, i) => (\n <PendingRow\n key={p.MessageID}\n reward={p}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n {letters.length === 0 && shownPending.length === 0 ? (\n <EmptyState\n glyph=\"gift\"\n text={tab === \"all\" ? t(\"empty\") : t(\"emptyTab\")}\n />\n ) : null}\n {letters.map((l, i) => (\n <LetterRow\n key={l.MessageID}\n letter={l}\n now={now}\n onOpen={() => setOpened(l)}\n onClaimed={load}\n style={stagger(i + shownPending.length)}\n />\n ))}\n {cursor ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={loadingMore}\n onClick={() => void more()}\n >\n {t(\"more\")}\n </Button>\n ) : null}\n {hasRead ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => setAskClean(true)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"deleteRead\")}\n </Button>\n ) : null}\n </>\n )}\n\n {opened ? (\n <LetterPopup\n letter={opened}\n features={features}\n onClose={() => setOpened(null)}\n onRead={(id, readAt) =>\n setLetters((shown) =>\n (shown ?? []).map((l) =>\n l.MessageID === id ? { ...l, ReadAt: readAt } : l,\n ),\n )\n }\n onChanged={load}\n />\n ) : null}\n {askClean ? (\n <ConfirmPopup\n title={t(\"deleteRead\")}\n tone=\"red\"\n onClose={() => setAskClean(false)}\n onConfirm={cleanRead}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteReadAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {gifts ? (\n <GiftsPopup\n defs={defs}\n initial={gifts}\n onClose={() => setGifts(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction LetterIcon({\n icon,\n fallback,\n size = 34,\n}: {\n icon: string | null | undefined;\n fallback: Glyph;\n size?: number;\n}): ReactNode {\n const glyph =\n icon && (isGlyph(icon) || !/^https?:/i.test(icon)) ? icon : null;\n if (icon && /^https?:/i.test(icon))\n return (\n <img\n src={icon}\n alt=\"\"\n style={{\n width: size,\n height: size,\n borderRadius: 8,\n objectFit: \"cover\",\n }}\n />\n );\n return <Icon glyph={glyph ?? fallback} size={size} />;\n}\n\nfunction LetterRow({\n letter,\n now,\n onOpen,\n onClaimed,\n style,\n}: {\n letter: MailboxLetter;\n now: number;\n onOpen: () => void;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const state = letterState(letter, now);\n const text = letterText(letter, builtInText);\n const unread = !letter.ReadAt;\n const reward = grantLines(letter.Rewards);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n await onClaimed();\n };\n\n return (\n <div\n ref={ref}\n onClick={onOpen}\n className=\"idos-press\"\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n border: state === \"claimable\" ? `2px solid ${v.gold}` : panel.border,\n opacity: state === \"claimed\" || state === \"expired\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ position: \"relative\" }}>\n <LetterIcon\n icon={letter.Icon}\n fallback={CATEGORY_GLYPH[letter.Category]}\n />\n {unread ? (\n <span\n style={{\n position: \"absolute\",\n top: -2,\n right: -2,\n width: 10,\n height: 10,\n borderRadius: 5,\n background: v.red,\n }}\n />\n ) : null}\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n fontWeight: unread ? 800 : 600,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {text.subject || t(\"title\")}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n }}\n >\n {letter.SenderName ? <span>{letter.SenderName}</span> : null}\n <span>{new Date(letter.SentAt).toLocaleDateString()}</span>\n {state === \"claimable\" && expiresSoon(letter, now) ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n {reward.length > 0 ? <ResourceList lines={reward} size={20} /> : null}\n </div>\n {state === \"claimable\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={(e) => {\n e.stopPropagation();\n void claim();\n }}\n >\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : state === \"expired\" ? (\n <Icon glyph=\"clock\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction PendingRow({\n reward,\n onClaimed,\n style,\n}: {\n reward: MailboxPendingReward;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const text = letterText(reward, builtInText);\n const lines = grantLines(reward.Rewards);\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(reward.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : lines);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: `2px solid ${v.gold}`,\n ...style,\n }}\n >\n <Icon glyph={MODULE_GLYPH[reward.Module] ?? \"chest\"} size={34} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{text.subject}</div>\n {text.body ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {text.body}\n </div>\n ) : null}\n {lines.length > 0 ? <ResourceList lines={lines} size={20} /> : null}\n </div>\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction LetterPopup({\n letter,\n features,\n onClose,\n onRead,\n onChanged,\n}: {\n letter: MailboxLetter;\n features: FeatureRegistry;\n onClose: () => void;\n onRead: (id: string, readAt: string) => void;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const now = useNow(30_000);\n const [busy, setBusy] = useState(false);\n const [ask, setAsk] = useState<\"decline\" | null>(null);\n const [claimedNow, setClaimedNow] = useState(false);\n const text = letterText(letter, builtInText);\n const reward = grantLines(letter.Rewards);\n const state = claimedNow ? \"claimed\" : letterState(letter, now);\n\n // Opening a letter reads it — once, and only an unread one.\n useEffect(() => {\n if (letter.ReadAt) return;\n void client.mailbox.readMessages([letter.MessageID]).then((r) => {\n if (r.ok) onRead(letter.MessageID, new Date().toISOString());\n });\n }, [letter.MessageID]);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n setClaimedNow(true);\n await onChanged();\n };\n const remove = async (force: boolean) => {\n const res = await client.mailbox.deleteMessages([letter.MessageID], {\n force,\n });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(force ? t(\"declined\") : t(\"deleted\"));\n await onChanged();\n onClose();\n };\n const link = letter.Link;\n const follow = () => {\n if (link?.FeatureID) {\n onClose();\n features.open(link.FeatureID, link.Args ?? undefined);\n } else if (link?.Url && /^https:\\/\\//i.test(link.Url)) {\n window.open(link.Url, \"_blank\", \"noopener,noreferrer\");\n }\n };\n const canFollow =\n (!!link?.FeatureID && !!features.get(link.FeatureID)?.available) ||\n (!!link?.Url && /^https:\\/\\//i.test(link.Url));\n\n return (\n <Popup title={text.subject || t(\"title\")} onClose={onClose} width={440}>\n {letter.Banner && /^https?:/i.test(letter.Banner) ? (\n <img\n src={letter.Banner}\n alt=\"\"\n style={{\n width: \"100%\",\n borderRadius: 10,\n objectFit: \"cover\",\n maxHeight: 180,\n }}\n />\n ) : null}\n {letter.SenderName ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}: {letter.SenderName} ·{\" \"}\n {new Date(letter.SentAt).toLocaleString()}\n </div>\n ) : null}\n {text.body ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n whiteSpace: \"pre-wrap\",\n lineHeight: 1.4,\n }}\n >\n {text.body}\n </div>\n ) : null}\n {letter.SenderNote ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n fontStyle: \"italic\",\n }}\n >\n «{letter.SenderNote}»\n </div>\n ) : null}\n {reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <ResourceList lines={reward} size={34} gap={12} />\n {state === \"claimable\" && letter.ExpiresAt ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {state === \"claimable\" ? (\n <Button tone=\"green\" attract busy={busy} onClick={() => void claim()}>\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </div>\n ) : state === \"expired\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.textDim }}>\n {t(\"expired\")}\n </div>\n ) : null}\n {canFollow ? (\n <Button tone=\"blue\" onClick={follow}>\n {link?.Label || t(\"open\")}\n </Button>\n ) : null}\n {state === \"claimable\" && letter.IsTransfer ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"decline\")}>\n {t(\"decline\")}\n </Button>\n ) : state !== \"claimable\" ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => void remove(false)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"delete\")}\n </Button>\n ) : null}\n </div>\n {ask ? (\n <ConfirmPopup\n title={t(\"decline\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={() => remove(true)}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"declineAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n\n// ── Gifts ──────────────────────────────────────────────────────────────────\n\nfunction GiftsPopup({\n defs,\n initial,\n onClose,\n}: {\n defs: MailboxDefinitions;\n initial: MailboxArgs;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const social = Object.entries(defs.Gifts?.Social ?? {}).filter(\n ([, g]) => g && g.Enabled !== false,\n );\n const transferOn = defs.Gifts?.Transfer?.Enabled === true;\n const [mode, setMode] = useState<\"gift\" | \"transfer\">(\n initial.mode === \"transfer\" && transferOn\n ? \"transfer\"\n : social.length > 0\n ? \"gift\"\n : \"transfer\",\n );\n const [friends, setFriends] = useState<Friend[] | null>(null);\n const [status, setStatus] = useState<MailboxGiftStatusResponse | null>(null);\n\n const refresh = useCallback(async () => {\n const s = await client.mailbox.getGiftStatus();\n if (s.ok) setStatus(s.data);\n }, [client]);\n useEffect(() => {\n void client.social.getFriendsList().then((r) => {\n const list = (r.ok ? (r.data.Friends ?? []) : []) as Friend[];\n setFriends(\n [...list].sort((a, b) => friendName(a).localeCompare(friendName(b))),\n );\n });\n void refresh();\n }, [client, refresh]);\n\n return (\n <Popup title={t(\"sendGifts\")} onClose={onClose} width={460}>\n <Tabs<\"gift\" | \"transfer\">\n tabs={[\n ...(social.length > 0\n ? [{ id: \"gift\" as const, label: t(\"gift\"), glyph: \"gift\" }]\n : []),\n ...(transferOn\n ? [{ id: \"transfer\" as const, label: t(\"transfer\"), glyph: \"bag\" }]\n : []),\n ]}\n value={mode}\n onChange={setMode}\n />\n {friends === null ? (\n <Center minHeight={160}>\n <Spinner />\n </Center>\n ) : friends.length === 0 ? (\n <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />\n ) : mode === \"gift\" ? (\n <SocialGifts\n gifts={social}\n friends={friends}\n status={status}\n initialFriend={initial.friendUserID}\n onSent={refresh}\n />\n ) : (\n <Transfer\n defs={defs}\n friends={friends}\n status={status}\n initialFriend={initial.friendUserID}\n onSent={refresh}\n onDone={onClose}\n />\n )}\n </Popup>\n );\n}\n\nfunction FriendChip({\n friend,\n selected,\n done,\n onClick,\n}: {\n friend: Friend;\n selected: boolean;\n done?: boolean;\n onClick?: () => void;\n}): ReactNode {\n return (\n <div\n onClick={done ? undefined : onClick}\n className={done ? undefined : \"idos-press\"}\n style={{\n ...panel,\n padding: \"8px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n cursor: done ? \"default\" : \"pointer\",\n border: selected ? `2px solid ${v.gold}` : panel.border,\n opacity: done ? 0.55 : 1,\n }}\n >\n {friend.PublicData?.AvatarUrl ? (\n <img\n src={friend.PublicData.AvatarUrl}\n alt=\"\"\n style={{\n width: 28,\n height: 28,\n borderRadius: 14,\n objectFit: \"cover\",\n }}\n />\n ) : (\n <Icon glyph=\"user\" size={26} />\n )}\n <span\n style={{\n ...outlined,\n fontSize: 14,\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {friendName(friend)}\n </span>\n {done ? (\n <Icon glyph=\"check\" size={18} />\n ) : selected ? (\n <Icon glyph=\"gift\" size={18} />\n ) : null}\n </div>\n );\n}\n\nfunction SocialGifts({\n gifts,\n friends,\n status,\n initialFriend,\n onSent,\n}: {\n gifts: Array<\n [\n string,\n NonNullable<NonNullable<MailboxDefinitions[\"Gifts\"]>[\"Social\"]>[string],\n ]\n >;\n friends: Friend[];\n status: MailboxGiftStatusResponse | null;\n initialFriend?: string;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [giftID, setGiftID] = useState(gifts[0]?.[0] ?? \"\");\n const [picked, setPicked] = useState<Set<string>>(\n () =>\n new Set(\n initialFriend && friends.some((f) => f.UserID === initialFriend)\n ? [initialFriend]\n : [],\n ),\n );\n const [busy, setBusy] = useState(false);\n const now = useNow(30_000);\n const mine = status?.Social?.[giftID];\n const sentTo = new Set(mine?.SentTo ?? []);\n const open = friends.filter((f) => !sentTo.has(f.UserID));\n const gift = gifts.find(([id]) => id === giftID)?.[1];\n const price = bundleLines(gift?.Cost);\n\n const send = async (ids: string[]) => {\n if (ids.length === 0) return;\n setBusy(true);\n const res = await client.mailbox.sendGift(giftID, ids);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n if (res.data.Sent.length > 0) toast(t(\"giftSent\"), \"success\");\n if (res.data.Failed.length > 0)\n toast(`${t(\"someFailed\")}: ${words(res.data.Failed[0]?.Error)}`, \"error\");\n setPicked(new Set());\n await onSent();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"giftHint\")}\n </div>\n {gifts.length > 1 ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {gifts.map(([id, g]) => (\n <Card\n key={id}\n highlight={id === giftID}\n onClick={() => {\n setGiftID(id);\n setPicked(new Set());\n }}\n style={{ padding: 8 }}\n >\n <ResourceList lines={bundleLines(g?.Grant)} size={24} />\n </Card>\n ))}\n </div>\n ) : gift ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <ResourceList lines={bundleLines(gift.Grant)} size={30} gap={12} />\n </div>\n ) : null}\n {price.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n }}\n >\n {t(\"pricePerFriend\")}: <ResourceList lines={price} size={18} />\n </div>\n ) : null}\n {mine ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"leftInPeriod\")}: {mine.RemainingInPeriod}\n </span>\n <Timer at={mine.ResetsAt} now={now} label={t(\"resetsIn\")} />\n </div>\n ) : null}\n <div\n style={{ display: \"grid\", gap: 6, maxHeight: 280, overflowY: \"auto\" }}\n >\n {friends.map((f) => (\n <FriendChip\n key={f.UserID}\n friend={f}\n done={sentTo.has(f.UserID)}\n selected={picked.has(f.UserID)}\n onClick={() =>\n setPicked((s) => {\n const next = new Set(s);\n if (next.has(f.UserID)) next.delete(f.UserID);\n else next.add(f.UserID);\n return next;\n })\n }\n />\n ))}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n busy={busy}\n disabled={picked.size === 0}\n onClick={() => void send([...picked])}\n style={{ flex: 1 }}\n >\n {t(\"sendTo\")}\n {picked.size > 0 ? ` · ${picked.size}` : \"\"}\n </Button>\n <Button\n tone=\"gold\"\n attract={open.length > 0}\n busy={busy}\n disabled={open.length === 0}\n onClick={() => void send(open.map((f) => f.UserID))}\n style={{ flex: 1 }}\n >\n {t(\"toEveryone\")}\n </Button>\n </div>\n </div>\n );\n}\n\nfunction Transfer({\n defs,\n friends,\n status,\n initialFriend,\n onSent,\n onDone,\n}: {\n defs: MailboxDefinitions;\n friends: Friend[];\n status: MailboxGiftStatusResponse | null;\n initialFriend?: string;\n onSent: () => Promise<void>;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const state = useUserState();\n const catalog = useCatalog();\n const [to, setTo] = useState<string | null>(\n initialFriend && friends.some((f) => f.UserID === initialFriend)\n ? initialFriend\n : null,\n );\n const [amounts, setAmounts] = useState<Record<string, number>>({});\n const [note, setNote] = useState(\"\");\n const [ask, setAsk] = useState(false);\n // One key per attempt: a retry after a timeout reuses it, so the server never charges twice.\n const requestID = useRef(newRequestID());\n const options = useMemo(\n () =>\n transferOptions(\n defs.Gifts?.Transfer?.AllowedResources,\n state?.InventoryV2 as TransferInventory | undefined,\n ),\n [defs, state?.InventoryV2],\n );\n const entries = transferEntries(options, amounts);\n const friend = friends.find((f) => f.UserID === to) ?? null;\n const now = useNow(30_000);\n const fee = feePercent(defs.Gifts?.Transfer?.FeePercent);\n const receives = afterFee(entries, fee);\n const fixedFee = bundleLines(defs.Gifts?.Transfer?.FeeCost);\n\n const send = async () => {\n if (!to) return false;\n const res = await client.mailbox.sendTransfer(to, entries, {\n requestID: requestID.current,\n note: note.trim() || undefined,\n });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n requestID.current = newRequestID();\n toast(t(\"transferSent\"), \"success\");\n await onSent();\n onDone();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"transferHint\")}\n </div>\n {status?.Transfer ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"leftInPeriod\")}: {status.Transfer.RemainingInPeriod}\n </span>\n <Timer\n at={status.Transfer.ResetsAt}\n now={now}\n label={t(\"resetsIn\")}\n />\n </div>\n ) : null}\n {fee > 0 || fixedFee.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n }}\n >\n {fee > 0 ? (\n <span>{t(\"commission\").replace(\"{p}\", String(fee))}</span>\n ) : null}\n {fixedFee.length > 0 ? (\n <>\n <span>{t(\"fixedFee\")}:</span>\n <ResourceList lines={fixedFee} size={18} />\n </>\n ) : null}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"pickFriend\")}</div>\n <div\n style={{ display: \"grid\", gap: 6, maxHeight: 180, overflowY: \"auto\" }}\n >\n {friends.map((f) => (\n <FriendChip\n key={f.UserID}\n friend={f}\n selected={to === f.UserID}\n onClick={() => setTo(f.UserID)}\n />\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 6 }}>\n {options.map((o) => (\n <TransferRow\n key={o.key}\n option={o}\n value={amounts[o.key] ?? 0}\n name={\n o.kind === \"currency\"\n ? catalog.currencyName(o.id)\n : catalog.itemName(o.id)\n }\n onChange={(n) => setAmounts((a) => ({ ...a, [o.key]: n }))}\n />\n ))}\n </div>\n <input\n value={note}\n onChange={(e) => setNote(e.target.value)}\n maxLength={120}\n placeholder={t(\"notePlaceholder\")}\n style={{\n ...panel,\n padding: \"10px 12px\",\n fontSize: 14,\n color: v.text,\n outline: \"none\",\n }}\n />\n <Button\n tone=\"green\"\n disabled={!to || entries.length === 0 || receives.length === 0}\n onClick={() =>\n entries.length === 0 ? toast(t(\"nothingToSend\")) : setAsk(true)\n }\n >\n {t(\"sendTo\")}\n </Button>\n {ask && friend ? (\n <ConfirmPopup\n title={t(\"transfer\")}\n tone=\"gold\"\n onClose={() => setAsk(false)}\n onConfirm={send}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"transferAsk\").replace(\"{name}\", friendName(friend))}\n </div>\n <ResourceList\n lines={bundleLines({ Entries: entries })}\n size={28}\n gap={12}\n />\n {fee > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"friendGets\")}\n </div>\n <ResourceList\n lines={bundleLines({ Entries: receives })}\n size={22}\n gap={10}\n />\n </div>\n ) : null}\n {fixedFee.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"fixedFee\")}: <ResourceList lines={fixedFee} size={18} />\n </div>\n ) : null}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction TransferRow({\n option,\n value,\n name,\n onChange,\n}: {\n option: TransferOption;\n value: number;\n name: string;\n onChange: (n: number) => void;\n}): ReactNode {\n const disabled = option.max <= 0;\n const set = (n: number) =>\n onChange(Math.max(0, Math.min(option.max, Math.floor(n) || 0)));\n return (\n <div\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n opacity: disabled ? 0.5 : 1,\n }}\n >\n <ResourceBadge\n line={{ kind: option.kind, id: option.id, amount: option.owned }}\n size={24}\n />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{name}</div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"owned\")}: {option.owned}\n </div>\n </div>\n <input\n type=\"number\"\n min={0}\n max={option.max}\n disabled={disabled}\n value={value || \"\"}\n placeholder=\"0\"\n onChange={(e) => set(Number(e.target.value))}\n style={{\n ...panel,\n width: 80,\n padding: \"6px 8px\",\n fontSize: 15,\n textAlign: \"center\",\n color: v.text,\n outline: \"none\",\n }}\n />\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={disabled}\n onClick={() => set(option.max)}\n >\n max\n </Button>\n </div>\n );\n}\n"
57
+ },
58
+ {
59
+ "path": "model.ts",
60
+ "content": "// Pure helpers of the mailbox screen: which letters can be claimed, what a letter says when the\n// server sends only its template, the badge, and what a player may transfer to a friend. The server\n// owns every rule (expiry, limits, friendship, the transfer white list) — this only decides what the\n// screen shows. Covered by model.test.ts.\n\nimport type {\n MailboxLetter,\n MailboxPendingReward,\n ResourceEntry,\n TransferableResource,\n} from \"@idosgames/core\";\n\n/** Whether the title has the mailbox on (off by default) — until it is, the lobby hides the tab. */\nexport function mailboxEnabled(\n section: { Enabled?: boolean | null } | null | undefined,\n): boolean {\n return section?.Enabled === true;\n}\n\n/**\n * The lobby badge. Unread letters and rewards waiting overlap (a new letter with a gift is both), so\n * the bigger of the two — never their sum.\n */\nexport function mailBadge(\n counters:\n | { UnreadCount?: number | null; UnclaimedCount?: number | null }\n | null\n | undefined,\n): number {\n return Math.max(\n Number(counters?.UnreadCount ?? 0),\n Number(counters?.UnclaimedCount ?? 0),\n );\n}\n\nexport type LetterState = \"claimable\" | \"claimed\" | \"plain\" | \"expired\";\n\nexport function letterState(\n letter: Pick<MailboxLetter, \"HasRewards\" | \"ClaimedAt\" | \"ExpiresAt\">,\n now: number,\n): LetterState {\n if (letter.HasRewards && letter.ClaimedAt) return \"claimed\";\n if (letter.ExpiresAt && Date.parse(letter.ExpiresAt) <= now) return \"expired\";\n return letter.HasRewards ? \"claimable\" : \"plain\";\n}\n\n/** A letter's reward expires within `hours` — the row shows the timer. */\nexport function expiresSoon(\n letter: Pick<MailboxLetter, \"ExpiresAt\">,\n now: number,\n hours = 24,\n): boolean {\n if (!letter.ExpiresAt) return false;\n const left = Date.parse(letter.ExpiresAt) - now;\n return left > 0 && left <= hours * 3_600_000;\n}\n\n/** `{name}` placeholders of a text, filled from the letter's params; unknown ones stay as they are. */\nexport function fillParams(\n text: string,\n params: Record<string, string> | null | undefined,\n): string {\n if (!params) return text;\n return text.replace(/\\{(\\w+)\\}/g, (all, key: string) =>\n Object.prototype.hasOwnProperty.call(params, key)\n ? String(params[key])\n : all,\n );\n}\n\nexport interface LetterText {\n subject: string;\n body: string;\n}\n\n/**\n * What a letter says. Letters of the platform's own templates (a friend's gift, a missed season\n * reward…) come without text unless the publisher rewrote the template — the screen has those texts\n * in both languages (`builtIn`).\n */\nexport function letterText(\n letter: {\n Subject?: string | null;\n Body?: string | null;\n TemplateID?: string | null;\n TemplateParams?: Record<string, string> | null;\n },\n builtIn: (templateID: string) => LetterText | null,\n): LetterText {\n const fallback = letter.TemplateID ? builtIn(letter.TemplateID) : null;\n return {\n subject: fillParams(\n letter.Subject?.trim() || fallback?.subject || \"\",\n letter.TemplateParams,\n ),\n body: fillParams(\n letter.Body?.trim() || fallback?.body || \"\",\n letter.TemplateParams,\n ),\n };\n}\n\n/** Everything \"Claim all\" would take — for the button's count. */\nexport function claimableCount(\n letters: readonly MailboxLetter[],\n pending: readonly MailboxPendingReward[],\n now: number,\n): number {\n return (\n letters.filter((l) => letterState(l, now) === \"claimable\").length +\n pending.length\n );\n}\n\n/** Newer letters first; a page added after \"More\" never duplicates a letter already shown. */\nexport function mergePages(\n shown: readonly MailboxLetter[],\n next: readonly MailboxLetter[],\n): MailboxLetter[] {\n const seen = new Set(shown.map((l) => l.MessageID));\n return [...shown, ...next.filter((l) => !seen.has(l.MessageID))];\n}\n\n// ── Transfers ─────────────────────────────────────────────────────────────\n\n/** Minimal slice of the player's inventory the transfer form reads. */\nexport interface TransferInventory {\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n Items?: Record<string, { StackableAmount?: number } | null> | null;\n}\n\nexport interface TransferOption {\n /** Stable key of the row: \"VirtualCurrency:CO\", \"Item:health_potion\". */\n key: string;\n kind: \"currency\" | \"item\";\n id: string;\n catalogID: string | null;\n owned: number;\n /** The most that can go in one transfer: the title's cap, but never more than the player has. */\n max: number;\n}\n\n/**\n * What the player can transfer: the title's white list (currencies and stackable items only — the\n * server refuses the rest), with how much they own. Rows the player owns nothing of stay, disabled\n * (max 0), so the list does not change shape as balances move.\n */\nexport function transferOptions(\n allowed:\n readonly (TransferableResource | null | undefined)[] | null | undefined,\n inventory: TransferInventory | null | undefined,\n): TransferOption[] {\n const out: TransferOption[] = [];\n for (const r of allowed ?? []) {\n if (!r) continue;\n let option: Omit<TransferOption, \"max\"> | null = null;\n if (r.Type === \"VirtualCurrency\" && r.CurrencyID) {\n option = {\n key: `VirtualCurrency:${r.CurrencyID}`,\n kind: \"currency\",\n id: r.CurrencyID,\n catalogID: null,\n owned: Number(\n inventory?.VirtualCurrencies?.[r.CurrencyID]?.Amount ?? 0,\n ),\n };\n } else if (r.Type === \"Item\" && r.ItemID) {\n option = {\n key: `Item:${r.ItemID}`,\n kind: \"item\",\n id: r.ItemID,\n catalogID: r.CatalogID ?? null,\n owned: Number(inventory?.Items?.[r.ItemID]?.StackableAmount ?? 0),\n };\n }\n if (!option) continue;\n const cap =\n r.MaxAmountPerGift && r.MaxAmountPerGift > 0\n ? r.MaxAmountPerGift\n : Infinity;\n out.push({ ...option, max: Math.max(0, Math.min(cap, option.owned)) });\n }\n return out;\n}\n\n/** The entries of a transfer from the amounts typed: whole, positive, clamped to each row's max. */\nexport function transferEntries(\n options: readonly TransferOption[],\n amounts: Record<string, number>,\n): ResourceEntry[] {\n const entries: ResourceEntry[] = [];\n for (const o of options) {\n const raw = Math.floor(Number(amounts[o.key] ?? 0));\n const amount = Math.min(Number.isFinite(raw) ? raw : 0, o.max);\n if (amount <= 0) continue;\n entries.push(\n o.kind === \"currency\"\n ? { Type: \"VirtualCurrency\", CurrencyID: o.id, Amount: amount }\n : {\n Type: \"Item\",\n ItemID: o.id,\n ...(o.catalogID ? { CatalogID: o.catalogID } : {}),\n Amount: amount,\n },\n );\n }\n return entries;\n}\n\n/** Commission as the server applies it: 0–90 %, anything else clamped. */\nexport function feePercent(value: number | null | undefined): number {\n const p = Number(value ?? 0);\n if (!Number.isFinite(p) || p <= 0) return 0;\n return Math.min(p, 90);\n}\n\n/**\n * The fee of one entry by the platform convention (the server's RateResolver/AmountMath): rounded\n * UP, after snapping double noise to the nearest integer (100 × 0.1 must stay 10, not 11).\n */\nfunction feeOf(amount: number, percent: number): number {\n const v = (amount * percent) / 100;\n if (!(v > 0)) return 0;\n const nearest = Math.round(v);\n if (Math.abs(v - nearest) <= 1e-9 * Math.max(1, Math.abs(v)))\n return Math.min(nearest, amount);\n return Math.min(Math.ceil(v), amount);\n}\n\n/**\n * What the friend receives after the commission — per entry, the friend gets the entry minus its\n * fee; entries that end at zero drop out. The same rule as the server's, so the preview matches\n * the letter.\n */\nexport function afterFee(\n entries: readonly ResourceEntry[],\n percent: number,\n): ResourceEntry[] {\n const p = feePercent(percent);\n return entries\n .map((e) => {\n const amount = Number(e.Amount ?? 0);\n return { ...e, Amount: p === 0 ? amount : amount - feeOf(amount, p) };\n })\n .filter((e) => Number(e.Amount) > 0);\n}\n\n/** The idempotency key of ONE transfer attempt: a retry after a timeout reuses it, never charges twice. */\nexport function newRequestID(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) return c.randomUUID();\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;\n}\n"
61
+ },
62
+ {
63
+ "path": "module.ts",
64
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMailboxScreen, makeMailboxWatcher } from \"./MailboxScreen\";\nimport { mailboxEnabled } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mailbox as a feature of the game: letters with rewards, broadcasts, friends' gifts and\n// transfers, missed rewards of other systems. The always-on watcher keeps the badge — one cheap\n// counter read on start and whenever the player comes back to the tab; every mailbox action brings\n// fresh counters itself (mailbox:countersChanged). Hidden until the title turns the mailbox on.\nexport const mailboxModule: Module = defineModule({\n id: \"mailbox\",\n meta: { name: \"Mailbox\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const enabled = mailboxEnabled(\n configSection<{ Enabled?: boolean | null }>(ctx.client, \"Mailbox\"),\n );\n ctx.features.register({\n id: \"mailbox\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"social\",\n order: 85,\n available: false,\n Screen: makeMailboxScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"mailbox\",\n enabled,\n \"the mailbox is off on the title (Mailbox.Enabled)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeMailboxWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"mailbox\" }),\n describeActions: {},\n });\n },\n});\n"
65
+ },
66
+ {
67
+ "path": "module.meta.json",
68
+ "content": "{\n \"id\": \"mailbox\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Mailbox for any game: letters with rewards, publisher broadcasts, gifts from friends, resource transfers and missed rewards of other systems — with 'Claim all'.\",\n \"description\": \"A ready mailbox screen for ANY game, built on the @idosgames/react/ui kit and client.mailbox. Tabs by category (all, rewards, gifts, system); a letter opens with its text in the player's language, the sender, a banner, the rewards and a button to the screen or page the letter links to. 'Claim' per letter and 'Claim all' in one server operation; rewards of other systems the player has not taken yet (leaderboard cycle, team event prize, invite rewards) are shown on top and claimed right here. 'Send gifts': a free daily gift to friends ('to everyone' in one tap) and a transfer of own coins or items to one friend — declined or expired transfers come back. 'Delete read' cleans up. The lobby badge counts unread letters and rewards waiting. The server owns everything: limits, friendship, expiry, what can be transferred. Opens straight on a friend with ctx.features.open('mailbox', { friendUserID, mode: 'gift' | 'transfer' }) — the friends system does that from a friend's card. Registers the 'mailbox' feature, hidden until the title turns the mailbox on. Ships title-seed.json: the mailbox on, a welcome template, a daily coin gift and coin/potion transfers.\",\n \"provides\": [\n \"mailbox screen\",\n \"letters with rewards and claim all\",\n \"publisher broadcasts in the inbox\",\n \"gifts to friends\",\n \"resource transfers between friends\",\n \"missed rewards of other systems\",\n \"mailbox badge\"\n ],\n \"tags\": [\n \"mailbox\",\n \"mail\",\n \"inbox\",\n \"gifts\",\n \"rewards\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
69
+ }
70
+ ]
71
+ }