@idosgames/mcp 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +11 -71
  4. package/registry/index.json +109 -109
  5. package/registry/modules/ai.json +4 -4
  6. package/registry/modules/board-game.json +3 -3
  7. package/registry/modules/boosts.json +6 -6
  8. package/registry/modules/character.json +33 -5
  9. package/registry/modules/chat.json +25 -5
  10. package/registry/modules/collections.json +22 -6
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +6 -6
  13. package/registry/modules/deals.json +2 -2
  14. package/registry/modules/events.json +5 -5
  15. package/registry/modules/friends.json +25 -5
  16. package/registry/modules/idle-rpg.json +2 -2
  17. package/registry/modules/inventory.json +2 -2
  18. package/registry/modules/invite.json +5 -5
  19. package/registry/modules/leaderboard.json +21 -5
  20. package/registry/modules/lootboxes.json +6 -6
  21. package/registry/modules/mailbox.json +33 -5
  22. package/registry/modules/marketplace.json +34 -6
  23. package/registry/modules/mini-game.json +2 -2
  24. package/registry/modules/multiplayer.json +34 -10
  25. package/registry/modules/premium.json +6 -6
  26. package/registry/modules/profile.json +4 -4
  27. package/registry/modules/pvp.json +33 -5
  28. package/registry/modules/quests.json +5 -5
  29. package/registry/modules/rewards.json +6 -6
  30. package/registry/modules/season.json +5 -5
  31. package/registry/modules/store.json +4 -4
  32. package/registry/modules/tutorials.json +2 -2
  33. package/registry/modules/voxelcraft.json +264 -12
  34. package/registry/modules/workshop.json +49 -5
  35. package/registry/skills/authentication.json +1 -1
  36. package/registry/skills/blockchain-system.json +1 -1
  37. package/registry/skills/checkout-system.json +1 -1
  38. package/registry/skills/cloud-code.json +2 -2
  39. package/registry/skills/currency-system.json +1 -1
  40. package/registry/skills/idosgames-compose-modules.json +1 -1
  41. package/registry/skills/idosgames-getting-started.json +1 -1
  42. package/registry/skills/idosgames-module-contract.json +2 -2
  43. package/registry/skills/idosgames-project-structure.json +1 -1
  44. package/registry/skills/idosgames-ui-kit.json +2 -2
  45. package/registry/skills/marketplace-system.json +1 -1
  46. package/registry/skills/match-system.json +1 -1
  47. package/registry/skills/multiplayer-realtime.json +2 -2
  48. package/registry/skills/voxelcraft-worlds.json +2 -2
  49. package/registry/skills/workshop-system.json +2 -2
@@ -36,13 +36,29 @@
36
36
  "version": "0.1.1"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.21.1",
39
+ "@idosgames/core": "0.21.3",
40
40
  "@idosgames/module-sdk": "0.10.0",
41
- "@idosgames/react": "0.4.4",
41
+ "@idosgames/react": "0.4.5",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "5d7fccefb883ee98826e3d153614e26a62d92595bfe932d29c1d5213b6113672",
44
+ "contentHash": "357adb096d2521d4641e52549fea0bfe116457f862975fcc645850c488084fd6",
45
45
  "files": [
46
+ {
47
+ "path": "components/CycleReward.tsx",
48
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n configSection,\n errorText,\n grantedBy,\n Icon,\n outlined,\n Popup,\n ResourceList,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n enabledBoards,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"../model\";\nimport { t } from \"../i18n\";\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"
49
+ },
50
+ {
51
+ "path": "components/MilestonesPopup.tsx",
52
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { type GetMyProgressResponse } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n errorText,\n grantedBy,\n Icon,\n outlined,\n Popup,\n ProgressBar,\n ResourceList,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n} from \"@idosgames/react/ui\";\nimport {\n cachedProgress,\n milestoneRows,\n type LeaderboardDefinitionView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function 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"
53
+ },
54
+ {
55
+ "path": "components/RankRewardsPopup.tsx",
56
+ "content": "import { type ReactNode } from \"react\";\nimport {\n Card,\n grantLines,\n outlined,\n Popup,\n ResourceList,\n useStagger,\n} from \"@idosgames/react/ui\";\nimport { rewardForRank, type LeaderboardDefinitionView } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function 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"
57
+ },
58
+ {
59
+ "path": "components/TopList.tsx",
60
+ "content": "import { type ReactNode } from \"react\";\nimport { type LeaderboardUserEntry } from \"@idosgames/core\";\nimport {\n formatAmount,\n Icon,\n outlined,\n panel,\n toneSurface,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function 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 ...(mine\n ? toneSurface(\"blue\")\n : { background: 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 ?? v.wellSoft,\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"
61
+ },
46
62
  {
47
63
  "path": "i18n.ts",
48
64
  "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Leaders\",\n top: \"Top players\",\n empty: \"Be the first on the leaderboard!\",\n noBoards: \"No leaderboard yet. Add one in LiveOps → Leaderboards.\",\n you: \"You\",\n player: \"Player\",\n myScore: \"My score\",\n myRank: \"My rank\",\n endsIn: \"Ends in\",\n rankRewards: \"Rank rewards\",\n milestones: \"Milestones\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n lastCycle: \"Last round is over!\",\n yourPlace: \"Your place\",\n reward: \"Your reward\",\n notRanked: \"not ranked\",\n play: \"Play\",\n invite: \"Invite friends\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Лидеры\",\n top: \"Лучшие игроки\",\n empty: \"Станьте первым в таблице!\",\n noBoards: \"Лидерборда пока нет. Добавьте его в LiveOps → Leaderboards.\",\n you: \"Вы\",\n player: \"Игрок\",\n myScore: \"Мои очки\",\n myRank: \"Моё место\",\n endsIn: \"До конца\",\n rankRewards: \"Награды за место\",\n milestones: \"Вехи\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n lastCycle: \"Прошлый раунд завершён!\",\n yourPlace: \"Ваше место\",\n reward: \"Ваша награда\",\n notRanked: \"вне таблицы\",\n play: \"Играть\",\n invite: \"Пригласить друзей\",\n});\n"
@@ -53,7 +69,7 @@
53
69
  },
54
70
  {
55
71
  "path": "LeaderboardScreen.tsx",
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 configSection,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\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 // The live place: the table when the player is in it — `LastKnownRank` of the progress is the place\n // at the last recount, 0 for a player who has not been recounted yet (the table said #1 while the\n // card said \"—\").\n const myRank =\n users.find((u) => u.UserID === me)?.Rank || progress?.LastKnownRank || null;\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 ...toneSurface(\"blue\"),\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 label={t(\"myRank\")} value={myRank ? `#${myRank}` : \"—\"} />\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={myRank}\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 ...(mine\n ? toneSurface(\"blue\")\n : { background: 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 ?? v.wellSoft,\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"
72
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type GetLeaderboardResponse,\n type GetMyProgressResponse,\n} from \"@idosgames/core\";\nimport {\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Center,\n configSection,\n EmptyState,\n errorText,\n formatAmount,\n Icon,\n outlined,\n panel,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useGameLayout,\n useNow,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { boardAction, enabledBoards, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\nimport { MilestonesPopup } from \"./components/MilestonesPopup\";\nimport { RankRewardsPopup } from \"./components/RankRewardsPopup\";\nimport { TopList } from \"./components/TopList\";\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\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 // The live place: the table when the player is in it — `LastKnownRank` of the progress is the place\n // at the last recount, 0 for a player who has not been recounted yet (the table said #1 while the\n // card said \"—\").\n const myRank =\n users.find((u) => u.UserID === me)?.Rank || progress?.LastKnownRank || null;\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 ...toneSurface(\"blue\"),\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 label={t(\"myRank\")} value={myRank ? `#${myRank}` : \"—\"} />\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={myRank}\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"
57
73
  },
58
74
  {
59
75
  "path": "model.ts",
@@ -61,7 +77,7 @@
61
77
  },
62
78
  {
63
79
  "path": "module.ts",
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"
80
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeCycleRewardWatcher } from \"./components/CycleReward\";\nimport { makeLeaderboardScreen } 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"
65
81
  },
66
82
  {
67
83
  "path": "module.meta.json",
@@ -32,12 +32,12 @@
32
32
  "version": "0.1.0"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.21.1",
35
+ "@idosgames/core": "0.21.3",
36
36
  "@idosgames/module-sdk": "0.10.0",
37
- "@idosgames/react": "0.4.4",
37
+ "@idosgames/react": "0.4.5",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "6c8a331edfae931985270afe5648e401f71770c30ec3d9de6c36406808fd48e7",
40
+ "contentHash": "f3a7b41d26e7401c7ccc3c891828bcc4068be53ebc63d0e144bf1fc1a6918cf4",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -49,15 +49,15 @@
49
49
  },
50
50
  {
51
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 lootboxName,\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 {lootboxName(o.lootboxID, catalog.localize)}\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 ? `${lootboxName(option.lootboxID, catalog.localize)} ×${count}`\n : lootboxName(option.lootboxID, catalog.localize)\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"
52
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n 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 lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} />\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 ? `${lootboxName(option.lootboxID, catalog.localize)} ×${count}`\n : lootboxName(option.lootboxID, catalog.localize)\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 watchLootboxes(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
53
53
  },
54
54
  {
55
55
  "path": "model.ts",
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/**\n * A lootbox has no display name of its own in the engine — only its id. Shown as is, the player read\n * \"wooden_chest\"; this makes the id readable (\"Wooden chest\") when the title has no translation for it.\n */\nexport function lootboxName(\n id: string,\n localize: (text: string) => string,\n): string {\n const translated = localize(id);\n if (translated && translated !== id) return translated;\n const words = id.replace(/[_-]+/g, \" \").trim();\n return words ? words[0]!.toUpperCase() + words.slice(1) : id;\n}\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"
56
+ "content": "// Pure helpers of the chests screen — covered by model.test.ts.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\nimport type { LootboxDefinitions } from \"@idosgames/core\";\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/** 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/**\n * A lootbox has no display name of its own in the engine — only its id. Shown as is, the player read\n * \"wooden_chest\"; this makes the id readable (\"Wooden chest\") when the title has no translation for it.\n */\nexport function lootboxName(\n id: string,\n localize: (text: string) => string,\n): string {\n const translated = localize(id);\n if (translated && translated !== id) return translated;\n const words = id.replace(/[_-]+/g, \" \").trim();\n return words ? words[0]!.toUpperCase() + words.slice(1) : id;\n}\n\n/** Every way to open every lootbox of the title (one entry per price option). */\nexport function lootboxOptions(\n section: LootboxDefinitions | 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: LootboxDefinitions | undefined): boolean {\n return lootboxOptions(section).length > 0;\n}\n"
57
57
  },
58
58
  {
59
59
  "path": "module.ts",
60
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { 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"
60
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { LootboxesScreen, watchLootboxes } from \"./LootboxesScreen\";\nimport { hasTitleData } 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(configSection(ctx.client, \"Lootbox\"));\n ctx.features.setAvailable(\n \"lootboxes\",\n enabled,\n \"no lootboxes on the title (Lootbox)\",\n );\n if (enabled) watchLootboxes(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"lootboxes\" }),\n describeActions: {},\n });\n },\n});\n"
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
@@ -36,13 +36,41 @@
36
36
  "version": "0.1.0"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.21.1",
39
+ "@idosgames/core": "0.21.3",
40
40
  "@idosgames/module-sdk": "0.10.0",
41
- "@idosgames/react": "0.4.4",
41
+ "@idosgames/react": "0.4.5",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "fcc65bab202ca1ecae11ee184aee0064e11bc9e34253866ec34733eb339b66cb",
44
+ "contentHash": "9f7338ccb258dd490608900385f678997a4da6c7ac90d0ade01a89e79b7a5f41",
45
45
  "files": [
46
+ {
47
+ "path": "components/FriendChip.tsx",
48
+ "content": "import { type ReactNode } from \"react\";\nimport { Icon, outlined, panel, v } from \"@idosgames/react/ui\";\nimport { type Friend, friendName } from \"../helpers\";\n\nexport function 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"
49
+ },
50
+ {
51
+ "path": "components/GiftsPopup.tsx",
52
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type MailboxDefinitions,\n type MailboxGiftStatusResponse,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { Center, EmptyState, Popup, Spinner, Tabs } from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\nimport { SocialGifts } from \"./SocialGifts\";\nimport { Transfer } from \"./Transfer\";\nimport { type Friend, type MailboxArgs, friendName } from \"../helpers\";\n\nexport function 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"
53
+ },
54
+ {
55
+ "path": "components/LetterPopup.tsx",
56
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type MailboxLetter } from \"@idosgames/core\";\nimport { type FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n ConfirmPopup,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n Popup,\n ResourceList,\n Timer,\n useCelebrate,\n useNow,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { letterState, letterText } from \"../model\";\nimport { builtInText, t } from \"../i18n\";\nimport { words } from \"../helpers\";\n\nexport function 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"
57
+ },
58
+ {
59
+ "path": "components/LetterRows.tsx",
60
+ "content": "import { useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { type MailboxLetter, type MailboxPendingReward } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n type Glyph,\n grantedBy,\n grantLines,\n Icon,\n isGlyph,\n outlined,\n panel,\n ResourceList,\n Timer,\n useCelebrate,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { expiresSoon, letterState, letterText } from \"../model\";\nimport { builtInText, t } from \"../i18n\";\nimport { CATEGORY_GLYPH, MODULE_GLYPH, words } from \"../helpers\";\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\nexport function 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\nexport function 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"
61
+ },
62
+ {
63
+ "path": "components/SocialGifts.tsx",
64
+ "content": "import { useState, type ReactNode } from \"react\";\nimport {\n type MailboxDefinitions,\n type MailboxGiftStatusResponse,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n bundleLines,\n Button,\n Card,\n outlined,\n ResourceList,\n Timer,\n useNow,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\nimport { FriendChip } from \"./FriendChip\";\nimport { type Friend, words } from \"../helpers\";\n\nexport function 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"
65
+ },
66
+ {
67
+ "path": "components/Transfer.tsx",
68
+ "content": "import { useMemo, useRef, useState, type ReactNode } from \"react\";\nimport {\n type MailboxDefinitions,\n type MailboxGiftStatusResponse,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n bundleLines,\n Button,\n ConfirmPopup,\n outlined,\n panel,\n ResourceBadge,\n ResourceList,\n TextInput,\n Timer,\n useCatalog,\n useNow,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n afterFee,\n feePercent,\n newRequestID,\n transferEntries,\n transferOptions,\n type TransferInventory,\n type TransferOption,\n} from \"../model\";\nimport { t } from \"../i18n\";\nimport { FriendChip } from \"./FriendChip\";\nimport { type Friend, friendName, words } from \"../helpers\";\n\nexport function 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 <TextInput\n value={note}\n onChange={(e) => setNote(e.target.value)}\n maxLength={120}\n placeholder={t(\"notePlaceholder\")}\n style={{ padding: \"10px 12px\", fontSize: 14 }}\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 <TextInput\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={{ width: 80, padding: \"6px 8px\", textAlign: \"center\" }}\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"
69
+ },
70
+ {
71
+ "path": "helpers.ts",
72
+ "content": "import { type MailboxCategory } from \"@idosgames/core\";\nimport { errorText, type Glyph } from \"@idosgames/react/ui\";\nimport { mailError } from \"./i18n\";\n\nexport type Tab = \"all\" | \"Reward\" | \"Gift\" | \"System\";\n\n/** What `ctx.features.open(\"mailbox\", args)` understands — the friends system sends it. */\nexport interface MailboxArgs {\n friendUserID?: string;\n friendName?: string;\n mode?: \"gift\" | \"transfer\";\n}\n\nexport interface Friend {\n UserID: string;\n PublicData?: { Username?: string | null; AvatarUrl?: string | null } | null;\n}\n\nexport function words(error: string | null | undefined): string {\n return mailError(error) ?? errorText(error);\n}\n\nexport function 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\nexport const CATEGORY_GLYPH: Record<MailboxCategory, Glyph> = {\n System: \"bell\",\n Reward: \"chest\",\n Gift: \"gift\",\n Social: \"friends\",\n};\n\nexport const MODULE_GLYPH: Record<string, Glyph> = {\n Leaderboard: \"trophy\",\n CoopEvent: \"swords\",\n Season: \"crown\",\n Referral: \"link\",\n};\n"
73
+ },
46
74
  {
47
75
  "path": "i18n.ts",
48
76
  "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"
@@ -53,7 +81,7 @@
53
81
  },
54
82
  {
55
83
  "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/** How often the badge re-reads the counters while the game is on screen. */\nconst MAIL_POLL_MS = 2 * 60_000;\n\n/**\n * Badge on login, on coming back to the tab and every few minutes; mailbox actions refresh it\n * themselves.\n */\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 // The badge comes from the read's own answer, not only from the event: the counters usually\n // arrive WITH the login (before this watcher subscribed), and a read the central cache answers\n // emits nothing.\n const load = () =>\n void client.mailbox\n .getUnreadCount()\n .then((r) => r.ok && features.setBadge(\"mailbox\", mailBadge(r.data)));\n load();\n const onVisible = () => {\n if (document.visibilityState === \"visible\") load();\n };\n document.addEventListener(\"visibilitychange\", onVisible);\n // Letters also come while the player stays in the game (a friend's gift, a leaderboard prize):\n // look now and then, or the badge waited for the next tab switch.\n const id = setInterval(() => {\n if (document.visibilityState === \"visible\") load();\n }, MAIL_POLL_MS);\n return () => {\n off();\n clearInterval(id);\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"
84
+ "content": "import {\n type ModuleContext,\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type MailboxDefinitions,\n type MailboxLetter,\n type MailboxPendingReward,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n configSection,\n ConfirmPopup,\n EmptyState,\n grantedBy,\n Icon,\n outlined,\n pickLanguage,\n Spinner,\n Tabs,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { claimableCount, letterState, mailBadge, mergePages } from \"./model\";\nimport { t } from \"./i18n\";\nimport { GiftsPopup } from \"./components/GiftsPopup\";\nimport { LetterPopup } from \"./components/LetterPopup\";\nimport { LetterRow, PendingRow } from \"./components/LetterRows\";\nimport { type MailboxArgs, type Tab, words } from \"./helpers\";\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\n/** How often the badge re-reads the counters while the game is on screen. */\nconst MAIL_POLL_MS = 2 * 60_000;\n\n/**\n * Badge on login, on coming back to the tab and every few minutes; mailbox actions refresh it\n * themselves.\n */\nexport function watchMailbox(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const off = client.on(\"mailbox:countersChanged\", (c) =>\n features.setBadge(\"mailbox\", mailBadge(c)),\n );\n // The badge comes from the read's own answer, not only from the event: the counters usually\n // arrive WITH the login (before this watcher subscribed), and a read the central cache answers\n // emits nothing.\n const load = () =>\n void client.mailbox\n .getUnreadCount()\n .then((r) => r.ok && features.setBadge(\"mailbox\", mailBadge(r.data)));\n load();\n const onVisible = () => {\n if (document.visibilityState === \"visible\") load();\n };\n document.addEventListener(\"visibilitychange\", onVisible);\n // Letters also come while the player stays in the game (a friend's gift, a leaderboard prize):\n // look now and then, or the badge waited for the next tab switch.\n const id = setInterval(() => {\n if (document.visibilityState === \"visible\") load();\n }, MAIL_POLL_MS);\n return () => {\n off();\n clearInterval(id);\n document.removeEventListener(\"visibilitychange\", onVisible);\n };\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"
57
85
  },
58
86
  {
59
87
  "path": "model.ts",
@@ -61,7 +89,7 @@
61
89
  },
62
90
  {
63
91
  "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"
92
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMailboxScreen, watchMailbox } 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) watchMailbox(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"mailbox\" }),\n describeActions: {},\n });\n },\n});\n"
65
93
  },
66
94
  {
67
95
  "path": "module.meta.json",