@idosgames/mcp 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +135 -135
  5. package/registry/modules/ai.json +3 -3
  6. package/registry/modules/board-game.json +15 -15
  7. package/registry/modules/boosts.json +3 -3
  8. package/registry/modules/character.json +8 -8
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +3 -3
  11. package/registry/modules/coop-events.json +3 -3
  12. package/registry/modules/craft.json +3 -3
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +7 -7
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/hero-run.json +20 -16
  17. package/registry/modules/idle-rpg.json +17 -13
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/leaderboard.json +7 -7
  21. package/registry/modules/lootboxes.json +7 -7
  22. package/registry/modules/mailbox.json +7 -7
  23. package/registry/modules/marketplace.json +3 -3
  24. package/registry/modules/mini-game.json +7 -7
  25. package/registry/modules/multiplayer.json +3 -3
  26. package/registry/modules/premium.json +3 -3
  27. package/registry/modules/profile.json +3 -3
  28. package/registry/modules/pvp.json +3 -3
  29. package/registry/modules/quests.json +3 -3
  30. package/registry/modules/rewards.json +5 -5
  31. package/registry/modules/season.json +3 -3
  32. package/registry/modules/store.json +7 -7
  33. package/registry/modules/tutorials.json +7 -7
  34. package/registry/modules/video-studio.json +4 -4
  35. package/registry/modules/voxelcraft.json +36 -28
  36. package/registry/modules/workshop.json +3 -3
  37. package/registry/skills/ai-generation-system.json +2 -2
  38. package/registry/skills/idosgames-ui-kit.json +1 -1
  39. package/registry/skills/rewarded-ads.json +1 -1
  40. package/registry/skills/store-system.json +1 -1
@@ -33,15 +33,15 @@
33
33
  "name": "iDos Games",
34
34
  "url": "https://idosgames.com"
35
35
  },
36
- "version": "0.1.4"
36
+ "version": "0.1.5"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.24.2",
40
- "@idosgames/module-sdk": "0.13.1",
41
- "@idosgames/react": "0.6.2",
39
+ "@idosgames/core": "0.25.0",
40
+ "@idosgames/module-sdk": "0.13.2",
41
+ "@idosgames/react": "0.6.3",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "fa0f2dfc6084b8c9650f50c14150a8a9e127ccb27f365d5a87e31af126786f11",
44
+ "contentHash": "8aa99b6b9980f06e2a66adf38ff0c1a20d5e2eefc197a5a81b75c1140faf0b66",
45
45
  "files": [
46
46
  {
47
47
  "path": "components/CycleReward.tsx",
@@ -69,7 +69,7 @@
69
69
  },
70
70
  {
71
71
  "path": "LeaderboardScreen.tsx",
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 Center,\n configSection,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n Icon,\n Spinner,\n U,\n UButton,\n UIconSlider,\n UScreen,\n Tabs,\n v,\n UTimer,\n UTopBar,\n uText,\n useCatalog,\n useNow,\n useToast,\n} from \"@idosgames/react/ui\";\nimport { boardAction, enabledBoards, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\nimport {\n MilestonesPopup,\n milestoneProgress,\n} from \"./components/MilestonesPopup\";\nimport { RankRewardsPopup } from \"./components/RankRewardsPopup\";\nimport { EntryRow, TopList } from \"./components/TopList\";\n\n// Leaderboards (Unity LeaderboardWindow, the Layer Lab look of Board_Survival): a violet top strip —\n// the dark title bar with my place in the black corner, the orange milestone slider with its timer\n// (tap → the milestone rewards) and the golden trophy at its right edge (tap → the rank rewards);\n// three podium flags, the entries from the 4th place, the player's own yellow row pinned at the\n// bottom over the boards' pill tabs. A game that has something to PLAY for the score registers a\n// \"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 toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const mine = users.find((u) => u.UserID === me);\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 = mine?.Rank || progress?.LastKnownRank || null;\n const myScore = Number(mine?.Score ?? progress?.CurrentScore ?? 0);\n\n // The slider runs to the next milestone not yet reached (Unity \"13/50\"), past the last — full.\n const earned = milestoneProgress(progress);\n const steps = Object.values(board.Milestones ?? {})\n .map((m) => Number(m?.RequiredProgress ?? 0))\n .filter((n) => n > 0)\n .sort((a, b) => a - b);\n const nextStep = steps.find((n) => n > earned) ?? steps[steps.length - 1];\n const hasRanks = (board.RankRewards?.length ?? 0) > 0;\n const left = countdown(top?.CycleEndUtc, now);\n\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 // Bottom: the player's row (ListFrame01_Yellow), the call to action, the board tabs.\n const footer =\n cta !== null ? (\n <div\n style={{\n background: v.nav,\n borderTop: `3px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n {/* one button of its own size in the middle — not a bar across the window */}\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 320px)\",\n justifyContent: \"center\",\n gap: 8,\n padding: \"10px 12px\",\n }}\n >\n {cta === \"play\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </UButton>\n ) : cta === \"invite\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={20} /> {t(\"invite\")}\n </UButton>\n ) : null}\n </div>\n </div>\n ) : undefined;\n\n return (\n <UScreen footer={footer}>\n {/* ── Top: the title bar, the milestone slider + timer, the rank-rewards trophy ── */}\n <div\n style={{\n background: `color-mix(in srgb, ${v.bgTop} 85%, #fff)`,\n borderBottom: `3px solid ${U.ink}`,\n }}\n >\n <UTopBar\n title={catalog.localize(board.DisplayName ?? id)}\n cornerLabel={t(\"myRank\")}\n corner={myRank ? `#${myRank}` : \"—\"}\n />\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n alignItems: \"center\",\n gap: 6,\n // room for the trophy on the right; the trophy itself never sets the row's height\n padding: hasRanks ? \"10px 64px 10px 64px\" : \"10px 12px\",\n }}\n >\n <div style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n {nextStep !== undefined ? (\n <div style={{ width: \"100%\", position: \"relative\" }}>\n <UIconSlider\n glyph=\"trophy\"\n value={Math.min(earned, nextStep)}\n max={nextStep}\n onClick={() => setPopup(\"milestones\")}\n />\n {progress?.HasUnclaimedMilestone ? (\n <span\n style={{\n position: \"absolute\",\n left: 38,\n top: -8,\n width: 18,\n height: 18,\n borderRadius: \"50%\",\n background: v.red,\n border: `2px solid ${U.ink}`,\n animation: \"idos-u-attract 1.2s ease-in-out infinite\",\n }}\n />\n ) : null}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {left ? <UTimer>{left}</UTimer> : null}\n {top?.TotalParticipants ? (\n <span style={uText(13)}>\n <Icon glyph=\"friends\" size={16} />{\" \"}\n {formatAmount(Number(top.TotalParticipants))}\n </span>\n ) : null}\n </div>\n </div>\n {hasRanks ? (\n <button\n type=\"button\"\n aria-label={t(\"rankRewards\")}\n onClick={() => setPopup(\"ranks\")}\n className=\"idos-press\"\n style={{\n // over the row, centred on its right edge: whatever its size, the row keeps the\n // height of the timer line (owner, 2026-10-22: no rays, no taller row)\n position: \"absolute\",\n right: 10,\n top: \"50%\",\n transform: \"translateY(-50%)\",\n width: 48,\n height: 48,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span\n style={{\n animation: \"idos-u-float 2.4s ease-in-out infinite\",\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph=\"trophy\" size={42} />\n </span>\n </button>\n ) : null}\n </div>\n </div>\n\n {/* ── The boards as tabs (\"All | Hero\"), the player's own gold row, then everyone ── */}\n {boards.length > 1 ? (\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 style={{\n borderRadius: 0,\n borderLeft: \"none\",\n borderRight: \"none\",\n borderTop: \"none\",\n }}\n />\n ) : null}\n {me && !loading ? (\n <EntryRow\n entry={\n mine ?? {\n UserID: me,\n Score: myScore,\n Rank: myRank ?? undefined,\n }\n }\n place={myRank ?? 0}\n me={me}\n />\n ) : null}\n <div\n style={{\n flex: 1,\n display: \"grid\",\n alignContent: \"start\",\n gap: 0,\n padding: \"0 0 12px\",\n }}\n >\n {loading ? (\n <Center minHeight={160}>\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 {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 onOpenVip={\n features.get(\"premium\")?.available\n ? (premiumID) => features.open(\"premium\", { premiumID })\n : undefined\n }\n />\n ) : null}\n </UScreen>\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 Center,\n configSection,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n Icon,\n Spinner,\n U,\n UButton,\n UIconSlider,\n UScreen,\n Tabs,\n v,\n UTimer,\n UTopBar,\n uText,\n useCatalog,\n useNow,\n useToast,\n} from \"@idosgames/react/ui\";\nimport { boardAction, enabledBoards, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\nimport {\n MilestonesPopup,\n milestoneProgress,\n} from \"./components/MilestonesPopup\";\nimport { RankRewardsPopup } from \"./components/RankRewardsPopup\";\nimport { EntryRow, TopList } from \"./components/TopList\";\n\n// Leaderboards (Unity LeaderboardWindow, the Layer Lab look of Board_Survival): a violet top strip —\n// the dark title bar with my place in the black corner, the orange milestone slider with its timer\n// (tap → the milestone rewards) and the golden trophy at its right edge (tap → the rank rewards);\n// three podium flags, the entries from the 4th place, the player's own yellow row pinned at the\n// bottom over the boards' pill tabs. A game that has something to PLAY for the score registers a\n// \"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 toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const mine = users.find((u) => u.UserID === me);\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 = mine?.Rank || progress?.LastKnownRank || null;\n const myScore = Number(mine?.Score ?? progress?.CurrentScore ?? 0);\n\n // The slider runs to the next milestone not yet reached (Unity \"13/50\"), past the last — full.\n const earned = milestoneProgress(progress);\n const steps = Object.values(board.Milestones ?? {})\n .map((m) => Number(m?.RequiredProgress ?? 0))\n .filter((n) => n > 0)\n .sort((a, b) => a - b);\n const nextStep = steps.find((n) => n > earned) ?? steps[steps.length - 1];\n const hasRanks = (board.RankRewards?.length ?? 0) > 0;\n const left = countdown(top?.CycleEndUtc, now);\n\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 // Bottom: the player's row (ListFrame01_Yellow), the call to action, the board tabs.\n const footer =\n cta !== null ? (\n <div\n style={{\n background: v.nav,\n borderTop: `3px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n {/* one button of its own size in the middle — not a bar across the window */}\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 320px)\",\n justifyContent: \"center\",\n gap: 8,\n padding: \"10px 12px\",\n }}\n >\n {cta === \"play\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </UButton>\n ) : cta === \"invite\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={20} /> {t(\"invite\")}\n </UButton>\n ) : null}\n </div>\n </div>\n ) : undefined;\n\n return (\n <UScreen footer={footer}>\n {/* ── Top: the title bar, the milestone slider + timer, the rank-rewards trophy ── */}\n <div\n style={{\n background: `color-mix(in srgb, ${v.bgTop} 85%, #fff)`,\n borderBottom: `3px solid ${U.ink}`,\n }}\n >\n <UTopBar\n title={catalog.localize(board.DisplayName ?? id)}\n cornerLabel={t(\"myRank\")}\n corner={myRank ? `#${myRank}` : \"—\"}\n />\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n alignItems: \"center\",\n gap: 6,\n // room for the trophy on the right; the trophy itself never sets the row's height\n padding: hasRanks ? \"10px 64px 10px 64px\" : \"10px 12px\",\n }}\n >\n <div style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n {nextStep !== undefined ? (\n <div style={{ width: \"100%\", position: \"relative\" }}>\n <UIconSlider\n glyph=\"trophy\"\n value={Math.min(earned, nextStep)}\n max={nextStep}\n onClick={() => setPopup(\"milestones\")}\n />\n {progress?.HasUnclaimedMilestone ? (\n <span\n style={{\n position: \"absolute\",\n left: 38,\n top: -8,\n width: 18,\n height: 18,\n borderRadius: \"50%\",\n background: v.red,\n border: `2px solid ${U.ink}`,\n animation: \"idos-u-attract 1.2s ease-in-out infinite\",\n }}\n />\n ) : null}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {left ? <UTimer>{left}</UTimer> : null}\n {top?.TotalParticipants ? (\n <span style={uText(13)}>\n <Icon glyph=\"friends\" size={16} />{\" \"}\n {formatAmount(Number(top.TotalParticipants))}\n </span>\n ) : null}\n </div>\n </div>\n {hasRanks ? (\n <button\n type=\"button\"\n aria-label={t(\"rankRewards\")}\n onClick={() => setPopup(\"ranks\")}\n className=\"idos-press\"\n style={{\n // over the row, centred on its right edge: whatever its size, the row keeps the\n // height of the timer line (owner, 2026-10-22: no rays, no taller row)\n position: \"absolute\",\n right: 10,\n top: \"50%\",\n transform: \"translateY(-50%)\",\n width: 48,\n height: 48,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span\n style={{\n animation: \"idos-u-float 2.4s ease-in-out infinite\",\n }}\n >\n <Icon glyph=\"trophy\" size={42} />\n </span>\n </button>\n ) : null}\n </div>\n </div>\n\n {/* ── The boards as tabs (\"All | Hero\"), the player's own gold row, then everyone ── */}\n {boards.length > 1 ? (\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 style={{\n borderRadius: 0,\n borderLeft: \"none\",\n borderRight: \"none\",\n borderTop: \"none\",\n }}\n />\n ) : null}\n {me && !loading ? (\n <EntryRow\n entry={\n mine ?? {\n UserID: me,\n Score: myScore,\n Rank: myRank ?? undefined,\n }\n }\n place={myRank ?? 0}\n me={me}\n />\n ) : null}\n <div\n style={{\n flex: 1,\n display: \"grid\",\n alignContent: \"start\",\n gap: 0,\n padding: \"0 0 12px\",\n }}\n >\n {loading ? (\n <Center minHeight={160}>\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 {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 onOpenVip={\n features.get(\"premium\")?.available\n ? (premiumID) => features.open(\"premium\", { premiumID })\n : undefined\n }\n />\n ) : null}\n </UScreen>\n );\n}\n"
73
73
  },
74
74
  {
75
75
  "path": "model.ts",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  {
83
83
  "path": "module.meta.json",
84
- "content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
84
+ "content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.5\"\n}\n"
85
85
  }
86
86
  ]
87
87
  }
@@ -29,15 +29,15 @@
29
29
  "name": "iDos Games",
30
30
  "url": "https://idosgames.com"
31
31
  },
32
- "version": "0.1.3"
32
+ "version": "0.1.4"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.24.2",
36
- "@idosgames/module-sdk": "0.13.1",
37
- "@idosgames/react": "0.6.2",
35
+ "@idosgames/core": "0.25.0",
36
+ "@idosgames/module-sdk": "0.13.2",
37
+ "@idosgames/react": "0.6.3",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "f2bbbcd4ac394d7b2325749a9c440987e5abe8eb754aff076ed244cc5c29a272",
40
+ "contentHash": "7942c63623452b8e5f162adebd6123c2effacfe8adfe2e9a1cba739482e8b6ee",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  {
51
51
  "path": "LootboxesScreen.tsx",
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 KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\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} fallback=\"chest\" />\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/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\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<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\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 ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\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 <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n // The sheet's \"Tap it now!\": a blue burst — light from the middle, faint rays around it.\n backgroundColor: v.blueDeep,\n backgroundImage: `repeating-conic-gradient(from 0deg at 50% 45%, rgba(255,255,255,.07) 0 9deg, transparent 9deg 18deg), radial-gradient(ellipse at 50% 45%, color-mix(in srgb, ${v.blue} 65%, #fff) 0%, ${v.blue} 35%, ${v.blueDeep} 80%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(255,255,255,.35)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n filter: \"drop-shadow(0 10px 0 rgba(0,0,0,.3))\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.red} 70%, #fff) 0%, ${v.red} 70%)`,\n border: `3px solid ${v.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\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"
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 KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\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} fallback=\"chest\" />\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/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\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<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\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 ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\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 <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n // The sheet's \"Tap it now!\": a blue burst — light from the middle, faint rays around it.\n backgroundColor: v.blueDeep,\n backgroundImage: `repeating-conic-gradient(from 0deg at 50% 45%, rgba(255,255,255,.07) 0 9deg, transparent 9deg 18deg), radial-gradient(ellipse at 50% 45%, color-mix(in srgb, ${v.blue} 65%, #fff) 0%, ${v.blue} 35%, ${v.blueDeep} 80%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(255,255,255,.35)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.red} 70%, #fff) 0%, ${v.red} 70%)`,\n border: `3px solid ${v.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\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",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
64
- "content": "{\n \"id\": \"lootboxes\",\n \"type\": \"feature\",\n \"summary\": \"Chests for any game: the title's lootboxes with every way to open them, once or ten at a time, with an opening animation.\",\n \"description\": \"A ready chests screen (Unity LootboxOpeningPopUp) for ANY game, built on the @idosgames/react/ui kit and client.lootbox. Every lootbox of the title as a card for each way to open it (currencies, a chest item, a key); 'Open' once, and 'Open ×10' where the lootbox's ceiling (MaxOpenCount) and the player's balance allow — the box shakes while the server rolls it, bursts, and the rewards fly out. The inventory hands a chest or key item over (ctx.features.open('lootboxes', { itemID })) — the screen then shows what that item opens. Registers the 'lootboxes' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"lootbox list with every way to open\",\n \"opening animation with rewards\",\n \"open ten at once\",\n \"open a chest with a key item\",\n \"badge for openable chests\"\n ],\n \"tags\": [\"lootbox\", \"chest\", \"gacha\", \"rewards\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
64
+ "content": "{\n \"id\": \"lootboxes\",\n \"type\": \"feature\",\n \"summary\": \"Chests for any game: the title's lootboxes with every way to open them, once or ten at a time, with an opening animation.\",\n \"description\": \"A ready chests screen (Unity LootboxOpeningPopUp) for ANY game, built on the @idosgames/react/ui kit and client.lootbox. Every lootbox of the title as a card for each way to open it (currencies, a chest item, a key); 'Open' once, and 'Open ×10' where the lootbox's ceiling (MaxOpenCount) and the player's balance allow — the box shakes while the server rolls it, bursts, and the rewards fly out. The inventory hands a chest or key item over (ctx.features.open('lootboxes', { itemID })) — the screen then shows what that item opens. Registers the 'lootboxes' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"lootbox list with every way to open\",\n \"opening animation with rewards\",\n \"open ten at once\",\n \"open a chest with a key item\",\n \"badge for openable chests\"\n ],\n \"tags\": [\"lootbox\", \"chest\", \"gacha\", \"rewards\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
65
65
  }
66
66
  ]
67
67
  }
@@ -33,15 +33,15 @@
33
33
  "name": "iDos Games",
34
34
  "url": "https://idosgames.com"
35
35
  },
36
- "version": "0.1.2"
36
+ "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.24.2",
40
- "@idosgames/module-sdk": "0.13.1",
41
- "@idosgames/react": "0.6.2",
39
+ "@idosgames/core": "0.25.0",
40
+ "@idosgames/module-sdk": "0.13.2",
41
+ "@idosgames/react": "0.6.3",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "fc1055f63f1791b87f8b1ba7b07177a561f7512edc18ca1447597327c58e57ee",
44
+ "contentHash": "12cf31156c913e7275c83bbff9b555821f7eb8dff6241ceb569da20ed87de1eb",
45
45
  "files": [
46
46
  {
47
47
  "path": "components/FriendChip.tsx",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  {
83
83
  "path": "MailboxScreen.tsx",
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 Columns,\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/** A button under the letters: across the columns, its own size in the middle. */\nconst ACROSS = {\n gridColumn: \"1 / -1\",\n justifySelf: \"center\",\n minWidth: 200,\n} as const;\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 1 auto\", whiteSpace: \"nowrap\" }}\n >\n {t(\"claimAll\")} · {toClaim}\n </Button>\n ) : null}\n {giftsOn ? (\n <Button\n tone=\"gold\"\n onClick={() => setGifts({})}\n style={{ flex: \"1 1 auto\", whiteSpace: \"nowrap\" }}\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 // the letters: one column on a phone, side by side on a tablet or a PC\n <Columns gap={10} min={340}>\n {shownPending.length > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n gridColumn: \"1 / -1\",\n }}\n >\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 <div style={{ gridColumn: \"1 / -1\" }}>\n <EmptyState\n glyph=\"gift\"\n text={tab === \"all\" ? t(\"empty\") : t(\"emptyTab\")}\n />\n </div>\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 style={ACROSS}\n >\n {t(\"more\")}\n </Button>\n ) : null}\n {hasRead ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => setAskClean(true)}\n style={ACROSS}\n >\n <Icon glyph=\"trash\" size={16} /> {t(\"deleteRead\")}\n </Button>\n ) : null}\n </Columns>\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"
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 Columns,\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/** A button under the letters: across the columns, its own size in the middle. */\nconst ACROSS = {\n gridColumn: \"1 / -1\",\n justifySelf: \"center\",\n minWidth: 200,\n} as const;\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 return () => {\n off();\n document.removeEventListener(\"visibilitychange\", onVisible);\n };\n });\n // Letters also come while the player stays in the lobby or a game inside it (a friend's gift, a\n // leaderboard prize): look now and then, or the badge waited for the next tab switch. Through\n // ctx.every, which stays quiet while a game takes the whole screen (the badge is not visible) and\n // looks once on the way back. The first call is the load above, so the timer skips its own.\n let first = true;\n ctx.every(MAIL_POLL_MS, () => {\n if (first) {\n first = false;\n return;\n }\n if (document.visibilityState === \"visible\")\n void client.mailbox\n .getUnreadCount()\n .then((r) => r.ok && features.setBadge(\"mailbox\", mailBadge(r.data)));\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 1 auto\", whiteSpace: \"nowrap\" }}\n >\n {t(\"claimAll\")} · {toClaim}\n </Button>\n ) : null}\n {giftsOn ? (\n <Button\n tone=\"gold\"\n onClick={() => setGifts({})}\n style={{ flex: \"1 1 auto\", whiteSpace: \"nowrap\" }}\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 // the letters: one column on a phone, side by side on a tablet or a PC\n <Columns gap={10} min={340}>\n {shownPending.length > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n gridColumn: \"1 / -1\",\n }}\n >\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 <div style={{ gridColumn: \"1 / -1\" }}>\n <EmptyState\n glyph=\"gift\"\n text={tab === \"all\" ? t(\"empty\") : t(\"emptyTab\")}\n />\n </div>\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 style={ACROSS}\n >\n {t(\"more\")}\n </Button>\n ) : null}\n {hasRead ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => setAskClean(true)}\n style={ACROSS}\n >\n <Icon glyph=\"trash\" size={16} /> {t(\"deleteRead\")}\n </Button>\n ) : null}\n </Columns>\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"
85
85
  },
86
86
  {
87
87
  "path": "model.ts",
@@ -93,7 +93,7 @@
93
93
  },
94
94
  {
95
95
  "path": "module.meta.json",
96
- "content": "{\n \"id\": \"mailbox\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Mailbox for any game: letters with rewards, publisher broadcasts, gifts from friends, resource transfers and missed rewards of other systems — with 'Claim all'.\",\n \"description\": \"A ready mailbox screen for ANY game, built on the @idosgames/react/ui kit and client.mailbox. Tabs by category (all, rewards, gifts, system); a letter opens with its text in the player's language, the sender, a banner, the rewards and a button to the screen or page the letter links to. 'Claim' per letter and 'Claim all' in one server operation; rewards of other systems the player has not taken yet (leaderboard cycle, team event prize, invite rewards) are shown on top and claimed right here. 'Send gifts': a free daily gift to friends ('to everyone' in one tap) and a transfer of own coins or items to one friend — declined or expired transfers come back. 'Delete read' cleans up. The lobby badge counts unread letters and rewards waiting. The server owns everything: limits, friendship, expiry, what can be transferred. Opens straight on a friend with ctx.features.open('mailbox', { friendUserID, mode: 'gift' | 'transfer' }) — the friends system does that from a friend's card. Registers the 'mailbox' feature, hidden until the title turns the mailbox on. Ships title-seed.json: the mailbox on, a welcome template, a daily coin gift and coin/potion transfers.\",\n \"provides\": [\n \"mailbox screen\",\n \"letters with rewards and claim all\",\n \"publisher broadcasts in the inbox\",\n \"gifts to friends\",\n \"resource transfers between friends\",\n \"missed rewards of other systems\",\n \"mailbox badge\"\n ],\n \"tags\": [\n \"mailbox\",\n \"mail\",\n \"inbox\",\n \"gifts\",\n \"rewards\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
96
+ "content": "{\n \"id\": \"mailbox\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Mailbox for any game: letters with rewards, publisher broadcasts, gifts from friends, resource transfers and missed rewards of other systems — with 'Claim all'.\",\n \"description\": \"A ready mailbox screen for ANY game, built on the @idosgames/react/ui kit and client.mailbox. Tabs by category (all, rewards, gifts, system); a letter opens with its text in the player's language, the sender, a banner, the rewards and a button to the screen or page the letter links to. 'Claim' per letter and 'Claim all' in one server operation; rewards of other systems the player has not taken yet (leaderboard cycle, team event prize, invite rewards) are shown on top and claimed right here. 'Send gifts': a free daily gift to friends ('to everyone' in one tap) and a transfer of own coins or items to one friend — declined or expired transfers come back. 'Delete read' cleans up. The lobby badge counts unread letters and rewards waiting. The server owns everything: limits, friendship, expiry, what can be transferred. Opens straight on a friend with ctx.features.open('mailbox', { friendUserID, mode: 'gift' | 'transfer' }) — the friends system does that from a friend's card. Registers the 'mailbox' feature, hidden until the title turns the mailbox on. Ships title-seed.json: the mailbox on, a welcome template, a daily coin gift and coin/potion transfers.\",\n \"provides\": [\n \"mailbox screen\",\n \"letters with rewards and claim all\",\n \"publisher broadcasts in the inbox\",\n \"gifts to friends\",\n \"resource transfers between friends\",\n \"missed rewards of other systems\",\n \"mailbox badge\"\n ],\n \"tags\": [\n \"mailbox\",\n \"mail\",\n \"inbox\",\n \"gifts\",\n \"rewards\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
97
97
  }
98
98
  ]
99
99
  }
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.24.2",
40
- "@idosgames/module-sdk": "0.13.1",
41
- "@idosgames/react": "0.6.2",
39
+ "@idosgames/core": "0.25.0",
40
+ "@idosgames/module-sdk": "0.13.2",
41
+ "@idosgames/react": "0.6.3",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "158824605f43164105e68883333c347fd5110a14b1ccb43c68036db930bbfe5f",
@@ -27,15 +27,15 @@
27
27
  "name": "iDos Games",
28
28
  "url": "https://idosgames.com"
29
29
  },
30
- "version": "0.1.3"
30
+ "version": "0.1.4"
31
31
  },
32
32
  "dependencies": {
33
- "@idosgames/core": "0.24.2",
34
- "@idosgames/module-sdk": "0.13.1",
35
- "@idosgames/react": "0.6.2",
33
+ "@idosgames/core": "0.25.0",
34
+ "@idosgames/module-sdk": "0.13.2",
35
+ "@idosgames/react": "0.6.3",
36
36
  "react": "19.2.7"
37
37
  },
38
- "contentHash": "23c1996b36806b24d7649583b3bb0bbe07fb5ac0e8c9ae9c5267735ecec7820c",
38
+ "contentHash": "4e855049c6c583010b8e9b39d552de30cd8544278690fe903fcc8d2a7e451382",
39
39
  "files": [
40
40
  {
41
41
  "path": "i18n.ts",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  {
53
53
  "path": "MiniGamePanel.tsx",
54
- "content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll idos-screen\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n {/* the field takes a PC's width too (the stars sit at percentages of it) */}\n <div style={{ maxWidth: 1120, margin: \"0 auto\" }}>\n <Round features={features} />\n </div>\n </div>\n );\n };\n}\n\nfunction Round({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 100%)`,\n maxWidth: 520,\n margin: \"0 auto\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph=\"star\" size={72} />\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n {t(\"best\")}\n </div>\n {result !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 18,\n color: v.gold,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {t(\"yourScore\")}: {formatAmount(result)}\n {result > 0 && board ? (\n <div\n style={{ fontSize: 13, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"submitted\")}\n </div>\n ) : null}\n {bonus.map((line) => (\n <div\n key={line}\n style={{ fontSize: 14, color: v.green, textAlign: \"center\" }}\n >\n {line}\n </div>\n ))}\n </div>\n ) : null}\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => {\n setResult(null);\n setBonus([]);\n setPlaying(true);\n }}\n data-tutorial-anchor=\"play:start\"\n >\n ▶ {result === null ? t(\"play\") : t(\"playAgain\")}\n </Button>\n {board && features.get(\"leaderboard\")?.available ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n features.open(\"leaderboard\", {\n leaderboardID: board.LeaderboardID,\n })\n }\n >\n <Icon glyph=\"trophy\" size={18} /> {t(\"toTable\")}\n </Button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A round's share of the title's live-ops: tokens of every active event that pays for a round (a\n * client-safe CustomAction, capped per day by the event) and status in every season chain open to\n * clients. Returns what was granted, for the result card. Best-effort: a refusal (a cap reached,\n * no active season) just grants nothing.\n */\nasync function rewardRound(\n client: IDosGamesClient,\n localize: (text: string | null | undefined) => string,\n): Promise<string[]> {\n const lines: string[] = [];\n const eventIDs = eventsCountingRounds(\n configSection<TimedEventSection>(client, \"TimedEvent\"),\n );\n if (eventIDs.length > 0) {\n const active = await client.timedEvent.getActiveEvents();\n for (const e of active.ok ? (active.data.ActiveEvents ?? []) : []) {\n if (\n !e.TimedEventID ||\n !eventIDs.includes(e.TimedEventID) ||\n e.CanEarn === false ||\n !e.Type\n )\n continue;\n const res = await client.timedEvent.grantTokens(\n e.Type,\n e.TimedEventID,\n \"CustomAction\",\n undefined,\n undefined,\n { ActionName: PLAY_ROUND_ACTION },\n );\n const op = res.ok\n ? (res.data.Grant?.Standard?.EventTokens?.[0] as\n { Applied?: number; Amount?: number } | undefined)\n : undefined;\n const amount = Number(op?.Applied ?? op?.Amount ?? 0);\n if (amount > 0)\n lines.push(\n `+${formatAmount(amount)} ${localize(e.Content?.Token?.DisplayName ?? e.Content?.DisplayName ?? e.TimedEventID)}`,\n );\n }\n }\n for (const chain of seasonChainsForRounds(\n configSection<SeasonSection>(client, \"Season\"),\n )) {\n const res = await client.season.grantStatusTokens(chain, ROUND_STATUS);\n if (res.ok && Number(res.data.AmountGranted ?? 0) > 0)\n lines.push(\n `+${formatAmount(Number(res.data.AmountGranted))} ${t(\"seasonStatus\")}`,\n );\n }\n return lines;\n}\n"
54
+ "content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll idos-screen idos-fit\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n {/* the field takes a PC's width too (the stars sit at percentages of it) */}\n <div style={{ maxWidth: 1120, margin: \"0 auto\" }}>\n <Round features={features} />\n </div>\n </div>\n );\n };\n}\n\nfunction Round({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 100%)`,\n maxWidth: 520,\n margin: \"0 auto\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph=\"star\" size={72} />\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n {t(\"best\")}\n </div>\n {result !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 18,\n color: v.gold,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {t(\"yourScore\")}: {formatAmount(result)}\n {result > 0 && board ? (\n <div\n style={{ fontSize: 13, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"submitted\")}\n </div>\n ) : null}\n {bonus.map((line) => (\n <div\n key={line}\n style={{ fontSize: 14, color: v.green, textAlign: \"center\" }}\n >\n {line}\n </div>\n ))}\n </div>\n ) : null}\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => {\n setResult(null);\n setBonus([]);\n setPlaying(true);\n }}\n data-tutorial-anchor=\"play:start\"\n >\n ▶ {result === null ? t(\"play\") : t(\"playAgain\")}\n </Button>\n {board && features.get(\"leaderboard\")?.available ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n features.open(\"leaderboard\", {\n leaderboardID: board.LeaderboardID,\n })\n }\n >\n <Icon glyph=\"trophy\" size={18} /> {t(\"toTable\")}\n </Button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A round's share of the title's live-ops: tokens of every active event that pays for a round (a\n * client-safe CustomAction, capped per day by the event) and status in every season chain open to\n * clients. Returns what was granted, for the result card. Best-effort: a refusal (a cap reached,\n * no active season) just grants nothing.\n */\nasync function rewardRound(\n client: IDosGamesClient,\n localize: (text: string | null | undefined) => string,\n): Promise<string[]> {\n const lines: string[] = [];\n const eventIDs = eventsCountingRounds(\n configSection<TimedEventSection>(client, \"TimedEvent\"),\n );\n if (eventIDs.length > 0) {\n const active = await client.timedEvent.getActiveEvents();\n for (const e of active.ok ? (active.data.ActiveEvents ?? []) : []) {\n if (\n !e.TimedEventID ||\n !eventIDs.includes(e.TimedEventID) ||\n e.CanEarn === false ||\n !e.Type\n )\n continue;\n const res = await client.timedEvent.grantTokens(\n e.Type,\n e.TimedEventID,\n \"CustomAction\",\n undefined,\n undefined,\n { ActionName: PLAY_ROUND_ACTION },\n );\n const op = res.ok\n ? (res.data.Grant?.Standard?.EventTokens?.[0] as\n { Applied?: number; Amount?: number } | undefined)\n : undefined;\n const amount = Number(op?.Applied ?? op?.Amount ?? 0);\n if (amount > 0)\n lines.push(\n `+${formatAmount(amount)} ${localize(e.Content?.Token?.DisplayName ?? e.Content?.DisplayName ?? e.TimedEventID)}`,\n );\n }\n }\n for (const chain of seasonChainsForRounds(\n configSection<SeasonSection>(client, \"Season\"),\n )) {\n const res = await client.season.grantStatusTokens(chain, ROUND_STATUS);\n if (res.ok && Number(res.data.AmountGranted ?? 0) > 0)\n lines.push(\n `+${formatAmount(Number(res.data.AmountGranted))} ${t(\"seasonStatus\")}`,\n );\n }\n return lines;\n}\n"
55
55
  },
56
56
  {
57
57
  "path": "model.ts",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",
66
- "content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
66
+ "content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
67
67
  }
68
68
  ]
69
69
  }
@@ -39,9 +39,9 @@
39
39
  "version": "0.2.1"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.24.2",
43
- "@idosgames/module-sdk": "0.13.1",
44
- "@idosgames/react": "0.6.2",
42
+ "@idosgames/core": "0.25.0",
43
+ "@idosgames/module-sdk": "0.13.2",
44
+ "@idosgames/react": "0.6.3",
45
45
  "react": "19.2.7"
46
46
  },
47
47
  "contentHash": "03c011db581a59f3e98ea8696f113e02021db798e9a871be151d3e65726db2f8",
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.24.2",
36
- "@idosgames/module-sdk": "0.13.1",
37
- "@idosgames/react": "0.6.2",
35
+ "@idosgames/core": "0.25.0",
36
+ "@idosgames/module-sdk": "0.13.2",
37
+ "@idosgames/react": "0.6.3",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "0f604f01a3e78b950a1dd799e80391a40da4321bcc334d24ba5a77094f22ca6c",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.5"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.24.2",
38
- "@idosgames/module-sdk": "0.13.1",
39
- "@idosgames/react": "0.6.2",
37
+ "@idosgames/core": "0.25.0",
38
+ "@idosgames/module-sdk": "0.13.2",
39
+ "@idosgames/react": "0.6.3",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "2448dc72d4408919336d2f4011a8dd8903a913811c8ef00db2b64c21b77f5b52",
@@ -33,9 +33,9 @@
33
33
  "version": "0.1.2"
34
34
  },
35
35
  "dependencies": {
36
- "@idosgames/core": "0.24.2",
37
- "@idosgames/module-sdk": "0.13.1",
38
- "@idosgames/react": "0.6.2",
36
+ "@idosgames/core": "0.25.0",
37
+ "@idosgames/module-sdk": "0.13.2",
38
+ "@idosgames/react": "0.6.3",
39
39
  "react": "19.2.7"
40
40
  },
41
41
  "contentHash": "c374268f2fc7cfb1fb4a6073cceca7cb0666f85bc2f1c37bc4ba818233207b61",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.24.2",
38
- "@idosgames/module-sdk": "0.13.1",
39
- "@idosgames/react": "0.6.2",
37
+ "@idosgames/core": "0.25.0",
38
+ "@idosgames/module-sdk": "0.13.2",
39
+ "@idosgames/react": "0.6.3",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "f30292cdadf0595a434aaae91598c279add865f03c5ae9767d5b4227e9d479ba",
@@ -31,12 +31,12 @@
31
31
  "version": "0.1.3"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.24.2",
35
- "@idosgames/module-sdk": "0.13.1",
36
- "@idosgames/react": "0.6.2",
34
+ "@idosgames/core": "0.25.0",
35
+ "@idosgames/module-sdk": "0.13.2",
36
+ "@idosgames/react": "0.6.3",
37
37
  "react": "19.2.7"
38
38
  },
39
- "contentHash": "62f4f39dd5ef0dd3d72a43a5de98cbc1a4d3e1ccc161b08daf2a9f845e7f2652",
39
+ "contentHash": "ab99622e4ba739d6e189e21c2e41c38125b2b2d140c2c63f670dd2205dca3efc",
40
40
  "files": [
41
41
  {
42
42
  "path": "i18n.ts",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  {
58
58
  "path": "RewardsScreen.tsx",
59
- "content": "import { useEffect, 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 configSection,\n EmptyState,\n errorText,\n grantedBy,\n grantLines,\n Icon,\n inkFrame,\n inked,\n outlined,\n panel,\n Popup,\n Ribbon,\n tokenGlyph,\n TONES,\n type ButtonTone,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n dailyView,\n lastCardSpan,\n pickCalendar,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards in the casual sheet's \"Daily Bonus\" look: the red ribbon, a grid of blue cards with\n// the day's number above each — a milestone day on a coloured plate, today's card framed white and\n// glowing, the collected ones dimmed under a tick — and the green Claim. Quests are a system of their\n// own (the quests module).\n\n/** The plates of milestone days in turn (the sheet's green, purple and red specials). */\nconst MILESTONE_TONES: ButtonTone[] = [\"green\", \"purple\", \"red\", \"orange\"];\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n // The day turns by the clock (counted locally); opening the screen re-checks the state against\n // changes made elsewhere through the central cache.\n useEffect(() => {\n void client.cache.ensureState([\"Reward\"], { maxAgeMs: 60_000 });\n }, [client]);\n\n return <DailyCalendar rewardState={state?.Reward ?? null} />;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\ntype RewardState = {\n DailyCalendars?: Record<\n string,\n { CollectedDays?: number; LastClaimAt?: string } | undefined\n >;\n} | null;\n\nexport function DailyCalendar({\n rewardState,\n compact = false,\n onClaimed,\n}: {\n rewardState: RewardState;\n compact?: boolean;\n onClaimed?: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const layout = useGameLayout();\n const kit = useUiKit();\n const stagger = useStagger();\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n const todayRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n if (!calendar)\n return compact ? null : (\n <EmptyState glyph=\"calendar\" text={t(\"noCalendar\")} />\n );\n\n const view = dailyView(\n calendar,\n rewardState?.DailyCalendars?.[calendar.CalendarID],\n new Date(),\n );\n const columns = calendarColumns(layout, compact, view.days.length);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.reward.claimDailyReward(calendar.CalendarID);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(todayRef.current);\n const today = view.days.find((d) => d.state === \"today\");\n const granted = grantedBy(res.data);\n onClaimed?.();\n celebrate(granted.length > 0 ? granted : grantLines(today?.day.Rewards));\n };\n\n return (\n // A week is a compact card on any width — three columns stretched across a monitor made\n // giant tiles; the popup is narrower than this anyway.\n <div style={{ maxWidth: 520, margin: \"0 auto\" }}>\n {compact ? null : (\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n margin: \"4px 0 14px\",\n }}\n >\n <Ribbon tone=\"red\" size={22}>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </Ribbon>\n </div>\n )}\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n gap: 8,\n }}\n >\n {view.days.map(({ day, state }, i) => {\n const lines = grantLines(day.Rewards);\n const main = lines[0];\n const span =\n i === view.days.length - 1\n ? lastCardSpan(view.days.length, columns)\n : 1;\n const today = state === \"today\";\n const collected = state === \"collected\";\n const tone: ButtonTone = day.IsMilestone\n ? MILESTONE_TONES[\n view.days.slice(0, i).filter((d) => d.day.IsMilestone)\n .length % MILESTONE_TONES.length\n ]!\n : \"blue\";\n const [top, deep] = TONES[tone];\n return (\n <div\n key={day.DayNumber ?? i}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 3,\n ...stagger(i),\n }}\n >\n <span\n style={{\n ...inked,\n fontSize: 13,\n color: `color-mix(in srgb, ${v.blue} 35%, #fff)`,\n }}\n >\n {day.DayNumber ?? i + 1}\n </span>\n <div\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n position: \"relative\",\n width: \"100%\",\n aspectRatio: span > 1 ? undefined : \"1\",\n minHeight: 78,\n borderRadius: 10,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 40%, ${deep} 100%)`,\n // the white frame is an outline: the glow animates box-shadow and would wipe it\n outline: today ? \"3px solid #ffffff\" : undefined,\n boxShadow: today\n ? `0 0 16px 4px color-mix(in srgb, #fff 55%, transparent)`\n : `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.4)`,\n display: \"grid\",\n justifyItems: \"center\",\n alignContent: \"center\",\n gap: 2,\n padding: \"6px 2px\",\n boxSizing: \"border-box\",\n opacity: collected ? 0.6 : 1,\n }}\n >\n <div\n className={today ? \"idos-bounce\" : undefined}\n style={{ display: \"flex\", gap: 2 }}\n >\n {lines.slice(0, 2).map((line) => (\n <Icon\n key={`${line.kind}:${line.id}`}\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={day.IsMilestone ? 52 : 44}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...inked, fontSize: 18 }}>\n {main.amount}\n </span>\n ) : null}\n {collected ? (\n // a tick in the corner — the reward under it stays readable\n <span style={{ position: \"absolute\", right: 4, bottom: 4 }}>\n <Icon glyph=\"check\" size={28} />\n </span>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n {view.canClaim ? (\n <Button\n busy={busy}\n attract\n size=\"lg\"\n onClick={() => void claim()}\n data-tutorial-anchor=\"rewards:daily-claim\"\n >\n <Icon glyph=\"gift\" size={22} /> {t(\"claim\")}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n textAlign: \"center\",\n fontSize: 13,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"comeBackTomorrow\")}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ── Always-on: the daily bonus greets the player, and the badge ─────────────────────────────\n\n/**\n * Like the Unity DailyBonusAutoOpener: a claimable daily reward pops up once per session. Also keeps\n * the badge of the rewards screen (the daily bonus waiting).\n */\nexport function makeRewardsWatcher(features: FeatureRegistry) {\n return function RewardsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [popup, setPopup] = useState(false);\n const shownOnce = useRef(false);\n\n useEffect(() => {\n let cancelled = false;\n // The calendar is in the login state; the central cache asks the server only when the title\n // tracks comeback presence on read.\n void client.cache.ensureState([\"Reward\"]).then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n if (!calendar) return;\n const s = (client.data.user.state?.Reward as RewardState | undefined)\n ?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change of it. Subscribed directly: the cache\n // patches the state in place, so an effect keyed on the state object would not re-run after a\n // claim and the badge would stay lit. The minute timer opens the next day's claim.\n useEffect(() => {\n const update = () => {\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (client.data.user.state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n features.setBadge(\"rewards\", dailyReady ? 1 : 0);\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n const id = setInterval(update, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n\n if (!popup) return null;\n return (\n <Popup\n title={t(\"daily\")}\n onClose={() => setPopup(false)}\n width={440}\n variant=\"center\"\n queued\n >\n <DailyCalendar\n compact\n rewardState={(state?.Reward ?? null) as RewardState}\n onClaimed={() => setPopup(false)}\n />\n </Popup>\n );\n };\n}\n"
59
+ "content": "import { useEffect, 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 configSection,\n EmptyState,\n errorText,\n grantedBy,\n grantLines,\n Icon,\n inkFrame,\n inked,\n outlined,\n panel,\n Popup,\n Ribbon,\n tokenGlyph,\n TONES,\n type ButtonTone,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n dailyView,\n lastCardSpan,\n pickCalendar,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards in the casual sheet's \"Daily Bonus\" look: the red ribbon, a grid of blue cards with\n// the day's number above each — a milestone day on a coloured plate, today's card framed white and\n// glowing, the collected ones dimmed under a tick — and the green Claim. Quests are a system of their\n// own (the quests module).\n\n/** The plates of milestone days in turn (the sheet's green, purple and red specials). */\nconst MILESTONE_TONES: ButtonTone[] = [\"green\", \"purple\", \"red\", \"orange\"];\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n // The day turns by the clock (counted locally); opening the screen re-checks the state against\n // changes made elsewhere through the central cache.\n useEffect(() => {\n void client.cache.ensureState([\"Reward\"], { maxAgeMs: 60_000 });\n }, [client]);\n\n return <DailyCalendar rewardState={state?.Reward ?? null} />;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\ntype RewardState = {\n DailyCalendars?: Record<\n string,\n { CollectedDays?: number; LastClaimAt?: string } | undefined\n >;\n} | null;\n\nexport function DailyCalendar({\n rewardState,\n compact = false,\n onClaimed,\n}: {\n rewardState: RewardState;\n compact?: boolean;\n onClaimed?: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const layout = useGameLayout();\n const kit = useUiKit();\n const stagger = useStagger();\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n const todayRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n if (!calendar)\n return compact ? null : (\n <EmptyState glyph=\"calendar\" text={t(\"noCalendar\")} />\n );\n\n const view = dailyView(\n calendar,\n rewardState?.DailyCalendars?.[calendar.CalendarID],\n new Date(),\n );\n const columns = calendarColumns(layout, compact, view.days.length);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.reward.claimDailyReward(calendar.CalendarID);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(todayRef.current);\n const today = view.days.find((d) => d.state === \"today\");\n const granted = grantedBy(res.data);\n onClaimed?.();\n celebrate(granted.length > 0 ? granted : grantLines(today?.day.Rewards));\n };\n\n return (\n // A week is a compact card on any width — three columns stretched across a monitor made\n // giant tiles; the popup is narrower than this anyway.\n <div style={{ maxWidth: 520, margin: \"0 auto\" }}>\n {compact ? null : (\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n margin: \"4px 0 14px\",\n }}\n >\n <Ribbon tone=\"red\" size={22}>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </Ribbon>\n </div>\n )}\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n gap: 8,\n }}\n >\n {view.days.map(({ day, state }, i) => {\n const lines = grantLines(day.Rewards);\n const main = lines[0];\n const span =\n i === view.days.length - 1\n ? lastCardSpan(view.days.length, columns)\n : 1;\n const today = state === \"today\";\n const collected = state === \"collected\";\n const tone: ButtonTone = day.IsMilestone\n ? MILESTONE_TONES[\n view.days.slice(0, i).filter((d) => d.day.IsMilestone)\n .length % MILESTONE_TONES.length\n ]!\n : \"blue\";\n const [top, deep] = TONES[tone];\n return (\n <div\n key={day.DayNumber ?? i}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 3,\n ...stagger(i),\n }}\n >\n <span\n style={{\n ...inked,\n fontSize: 13,\n color: `color-mix(in srgb, ${v.blue} 35%, #fff)`,\n }}\n >\n {day.DayNumber ?? i + 1}\n </span>\n <div\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n position: \"relative\",\n width: \"100%\",\n aspectRatio: span > 1 ? undefined : \"1\",\n minHeight: 78,\n borderRadius: 10,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 40%, ${deep} 100%)`,\n // today: the white frame is an outline, the gold glow the kit's .idos-glow (its own layer)\n outline: today ? \"3px solid #ffffff\" : undefined,\n boxShadow: today\n ? undefined\n : `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.4)`,\n display: \"grid\",\n justifyItems: \"center\",\n alignContent: \"center\",\n gap: 2,\n padding: \"6px 2px\",\n boxSizing: \"border-box\",\n opacity: collected ? 0.6 : 1,\n }}\n >\n <div\n className={today ? \"idos-bounce\" : undefined}\n style={{ display: \"flex\", gap: 2 }}\n >\n {lines.slice(0, 2).map((line) => (\n <Icon\n key={`${line.kind}:${line.id}`}\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={day.IsMilestone ? 52 : 44}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...inked, fontSize: 18 }}>\n {main.amount}\n </span>\n ) : null}\n {collected ? (\n // a tick in the corner — the reward under it stays readable\n <span style={{ position: \"absolute\", right: 4, bottom: 4 }}>\n <Icon glyph=\"check\" size={28} />\n </span>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n {view.canClaim ? (\n <Button\n busy={busy}\n attract\n size=\"lg\"\n onClick={() => void claim()}\n data-tutorial-anchor=\"rewards:daily-claim\"\n >\n <Icon glyph=\"gift\" size={22} /> {t(\"claim\")}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n textAlign: \"center\",\n fontSize: 13,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"comeBackTomorrow\")}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ── Always-on: the daily bonus greets the player, and the badge ─────────────────────────────\n\n/**\n * Like the Unity DailyBonusAutoOpener: a claimable daily reward pops up once per session. Also keeps\n * the badge of the rewards screen (the daily bonus waiting).\n */\nexport function makeRewardsWatcher(features: FeatureRegistry) {\n return function RewardsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [popup, setPopup] = useState(false);\n const shownOnce = useRef(false);\n\n useEffect(() => {\n let cancelled = false;\n // The calendar is in the login state; the central cache asks the server only when the title\n // tracks comeback presence on read.\n void client.cache.ensureState([\"Reward\"]).then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n if (!calendar) return;\n const s = (client.data.user.state?.Reward as RewardState | undefined)\n ?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change of it. Subscribed directly: the cache\n // patches the state in place, so an effect keyed on the state object would not re-run after a\n // claim and the badge would stay lit. The minute timer opens the next day's claim.\n useEffect(() => {\n const update = () => {\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (client.data.user.state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n features.setBadge(\"rewards\", dailyReady ? 1 : 0);\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n const id = setInterval(update, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n\n if (!popup) return null;\n return (\n <Popup\n title={t(\"daily\")}\n onClose={() => setPopup(false)}\n width={440}\n variant=\"center\"\n queued\n >\n <DailyCalendar\n compact\n rewardState={(state?.Reward ?? null) as RewardState}\n onClaimed={() => setPopup(false)}\n />\n </Popup>\n );\n };\n}\n"
60
60
  },
61
61
  {
62
62
  "path": "module.meta.json",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.24.2",
40
- "@idosgames/module-sdk": "0.13.1",
41
- "@idosgames/react": "0.6.2",
39
+ "@idosgames/core": "0.25.0",
40
+ "@idosgames/module-sdk": "0.13.2",
41
+ "@idosgames/react": "0.6.3",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "e7d48360d17873bd96f35c06a96a69320d2802f688a71e47392cc7dbf2c1d893",